chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged

Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 23:13:21 +02:00
1 parent 324d4bb411
commit 6c7fc7dc84
167 files changed
+11233 -371

No files matched your search

+5
View File
@@ -49,6 +49,11 @@ next-env.d.ts
# prisma generated
/src/generated/prisma
# v1.4.43 QoL (L3) — generated at build time by
# scripts/generate-sw-version.mjs; loaded by public/sw.js to key
# the cache-version baseline.
/public/sw-version.js
# v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time
# via `scripts/fetch-geolite2.sh`. Too large for git; license key
# required. The folder + a `.gitkeep` are tracked so the Dockerfile
@@ -0,0 +1,66 @@
# Phase W11-MOBILE-UI-RESIDUAL — v1.4.43
**Branch**: `w11-mobile-ui-residual-v1443` (pushed to origin)
**Base**: `origin/develop` (rebased clean onto every new tip during the wave)
**Scope source**: `.planning/round-v1443-AUDIT-mobile-ui-findings.md`
## Items closed
| Item | Status | Commit |
|---|---|---|
| M1 — `Loader2 animate-spin` missing `motion-reduce:animate-none` (21 sites) | Closed | `16a85b53` |
| M2 — `phase-config-dialog.tsx` Input + Button 32 px + `text-sm` | Closed | `cd160a3f` |
| M3 — Insights dynamic skeletons height mismatch | Closed (with L4) | `37988bbb` |
| M4 — `correlation-card.tsx` scatter skeleton aspect-ratio mismatch | Closed | `f87265df` |
| M5 — Dashboard "blocked-then-burst" tile-strip during slow slim | Closed | `125a2e31` |
| M6 — `health-chart.tsx` empty-window silently `return null` | Closed | `24ff6754` |
| L1 — Insights tab-strip group-popover `min-h-10` | Closed | `3aca96ec` |
| L2 — `chart-overlay-controls.tsx` `size="sm"` + stretched `min-h-11` mix | Closed | `0b25ba40` |
| L4 — `next/dynamic` insights skeletons missing `motion-reduce:animate-none` | Closed (folded with M3) | `37988bbb` |
| L6 — `RecentWorkoutsTile` + `DrugLevelChart` unreserved loading height | Closed | `b6bc77f4` |
## Items deferred + why
- **L3 — Textarea primitive** — out of scope per handoff (`Defer (do NOT do in this wave) — L3 Textarea primitive — bigger refactor with 6+ call-sites, defer to v1.4.44 standalone wave`).
- **L5 — `injection-site-picker.tsx:178` `<div tabIndex={0} role="button">` → `<button type="button">`** — the audit description is incorrect about the element type. The actual code is an **SVG `<circle>` element**, not an HTML `<div>`. SVG circles cannot be converted to HTML `<button>` directly; replacing the picker's SVG hit zones with overlaid absolute-positioned HTML buttons is a non-trivial layout refactor that goes well beyond a one-line low-priority fix. The current implementation already exposes `role="button"`, `tabIndex={0}`, `aria-pressed`, `aria-label`, and a proper `onKeyDown` for Enter/Space, so it remains keyboard- and screen-reader-accessible. Deferring with a note for v1.4.44; if the layout overhaul is desired, that work should land standalone with a small Playwright/snapshot harness so the picker's geometry doesn't drift during the refactor.
## Test additions
| File | Tests | Purpose |
|---|---|---|
| `src/components/__tests__/motion-reduce-spin-coverage.test.ts` | 2 | Scans `src/**` for every `animate-spin` site and fails if any lack `motion-reduce:animate-none`. Future spinners can't drift silently. |
| `src/app/insights/__tests__/page-skeletons.test.ts` | 4 | Pins the three insights mother-page dynamic-skeleton heights + motion-reduce class; fails if the legacy `h-48/h-32/h-64` trio comes back. |
| `src/components/insights/__tests__/correlation-card-skeleton.test.ts` | 2 | Pins the scatter-chart aspect-ratio quartet on both the loaded chart and the skeleton mirror. |
| `src/app/__tests__/dashboard-tile-strip-skeleton.test.ts` | 6 | Six pins for the new M5 skeleton: `configuredTileCount` derivation, gate condition, grid track parity, card count, `aria-hidden` + motion-reduce, and EmptyState suppression while loading. |
| `src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx` | 1 | Asserts the new `charts.noDataInRange*` empty state paints and that the card chrome (title) stays mounted; verifies the < 3-points hint is suppressed so the two situations don't blur. |
**Net additions**: 5 new test files × 15 new test cases. Full unit-suite count: 4 815 → **4 861** (+46 — also reflects new tests landed on develop during the wave by other agents).
## Quality gates
- `pnpm typecheck` — clean (after `pnpm db:generate` to regenerate the Prisma client in the worktree).
- `pnpm lint` — clean.
- `pnpm test` — 461 files, 4 861 passed, 1 skipped, 0 failed.
- Touched-area component tests (charts/insights/dashboard/medications/admin/settings/mood/doctor-report) — 891 of 891 passed.
## i18n additions
Added `charts.noDataInRangeTitle` + `charts.noDataInRangeDescription` to all six locales (`de`/`en`/`es`/`fr`/`it`/`pl`) for the M6 empty-window state. Copy mirrors the existing chart-empty-state register (calm, action-oriented; distinct from the < 3-points "Mehr Messtage" register).
## Commit SHAs in chronological order
1. `16a85b53` — a11y(ui): pair animate-spin with motion-reduce:animate-none across 21 sites (M1)
2. `cd160a3f` — fix(medications): bump phase-config-dialog Input + Button to 44 px on mobile (M2)
3. `37988bbb` — fix(insights): match dynamic-skeleton heights to loaded content + motion-reduce (M3 + L4)
4. `f87265df` — fix(insights): mirror scatter-chart aspect ratio on correlation-card skeleton (M4)
5. `125a2e31` — fix(dashboard): paint tile-strip skeleton during slow slim-analytics fetch (M5)
6. `24ff6754` — fix(charts): paint empty-window state instead of erasing the card (M6)
7. `3aca96ec` — a11y(insights): bump tab-strip group-popover items to 44 px (L1)
8. `0b25ba40` — a11y(charts): use responsive sizing on chart-overlay-controls trigger (L2)
9. `b6bc77f4` — fix(ui): reserve loaded-card height on workouts + drug-level loading states (L6)
## Notes for the reconcile / final-merge step
- The `motion-reduce-spin-coverage` test scans `src/**` recursively. If a future wave introduces another spinner without the modifier, the test fails before merge — there's no longer a need to grep for the pattern manually during release prep.
- The M5 tile-strip skeleton **suppresses the EmptyState** while loading. If a future change adds another empty-state branch on the dashboard, mind the `!showTileStripSkeleton` clause so the skeleton can still paint during a slow-network cold mount.
- The M6 chart change keeps the card shell mounted when data is empty. Any caller that depends on the legacy "chart silently returns nothing" contract (e.g. a tile that gates on `chart.height > 0` via a `ref`) would now see the empty-state card instead. None found in the audit pass; mention in case downstream reviewers spot one.
@@ -0,0 +1,152 @@
# Phase W12-QOL-RESIDUAL — v1.4.43 report
## Branch
`w12-qol-residual-v1443` (based on `origin/develop`)
## Items closed
### Mediums
- **M3** Settings danger zone now offers a separate "Konto vollständig
löschen" action wired to the existing `DELETE /api/settings/account`
endpoint (which already cascades User + passkeys + audit log +
sessions). The previous single CTA only wiped health data — half the
GDPR Article 17 surface. Component is `AccountDeleteCard` in
`src/components/settings/advanced-section.tsx`.
- **M4** Doctor-report dialog now renders every section in
`SECTION_ORDER`; sections without data in the selected range show as
disabled toggles with strike-through labels + a tooltip
("Keine Daten in diesem Zeitraum"). The submission payload still
force-clears unavailable toggles so the server never renders empty
sections.
- **M5** New `<OfflineBanner>` component mounted at the top of
`<AuthShell>`. Listens for `online`/`offline` window events and
paints a slim warning-toned strip when offline. Bilingual via i18n —
all six locales ship the copy.
- **M6** `errorCodeToI18nKey` now splits `coach.network` to a new
`insights.coach.errorNetwork` key ("Keine Internetverbindung —
versuche es erneut, sobald du online bist") so users see the
actionable offline hint instead of the generic provider copy when
their own connection drops mid-stream.
- **M7** `messages/de.json` `integrationPill.daysAgo` "vor {count} T."
→ "vor {count} d", matching the existing `staleHint` "d" pattern
used elsewhere. The other five locales already shipped "d".
- **M8** `src/lib/format.ts` `activeLocale()` now reads the full
`Locale` union (`de | en | fr | es | it | pl`) via the shared
`isLocale()` type guard. Pre-fix a French / Spanish / Italian /
Polish user saw English-formatted dates because the legacy reader
only matched `de`/`en`.
### Lows
- **L1** Tightened 404 + global-error copy across all six locales.
`not-found.tsx` is now a server component that reads
`notFound.title` / `notFound.backToDashboard` via the existing
`getServerTranslator()`. `global-error.tsx` ships a bilingual
DE/EN lockup (no i18n provider available at that boundary). The
verbose v1.4.27 marketing paragraph is gone.
- **L2** Measurement-completion checklist copy now matches the real
threshold ("Ein Messpunkt reicht, um die Kachel zu aktivieren —
der Trend zeichnet sich ab dem 5. Eintrag.") across all six
locales. The `checklist.ts` `count >= 1` rule stays.
- **L3** `public/sw.js` `CACHE_VERSION` now reads from
`self.__APP_VERSION__` (loaded via `importScripts('/sw-version.js')`).
The generated `sw-version.js` is written by the new
`scripts/generate-sw-version.mjs` and wired as a `prebuild` step in
`package.json`. Pre-fix the literal had drifted four releases stale.
- **L4** `<ChartSkeleton>` paints a 3 s-delayed "Auswertungen werden
berechnet — das kann einen Moment dauern" caption (full variant
only; mini variant skips to avoid overflowing the trends-row
140 px slot). Bilingual via the new `charts.loadingSlowHint` key.
- **L5** Data-reset card in `<AdvancedSection>` drops the
`AlertTriangle` icon; title sits in `text-foreground` (neutral) and
only the CTA button stays red. Matches GitHub-style danger-zone
shaping per the audit recommendation.
- **L7** Code-comment in `measurement-list.tsx` pins the
`[Cancel] [Save]` button order as iOS-first intentional (Apple HIG)
so a future "Android parity" refactor can't silently flip it.
- **L8** New `formatDateOrRelative(iso, t, nowMs?)` helper in
`src/lib/format.ts`. Inside the last 24 h → relative
("vor 12 min"); older → absolute (`formatDateTime`). Wired to the
two `measurement-list.tsx` timestamp sites flagged in the audit.
## Items deferred (per handoff)
- **M1** drag-and-drop dashboard reorder — a11y story warrants its
own spec (defer to v1.4.44+).
- **M2** per-user Coach disable toggle — Settings UI feature work,
deferred.
- **L6** Onboarding chained-flow gate — UX decision, deferred.
## Test additions / changes
- `src/app/__tests__/not-found-copy.test.ts` (NEW, 16 tests) — pins
the tightened 404 copy across all six locales + the global-error
bilingual lockup.
- `src/lib/__tests__/format-active-locale.test.ts` (NEW, 8 tests) —
cookie-driven locale read for the legacy formatter (M8).
- `src/lib/__tests__/format-date-or-relative.test.ts` (NEW, 11 tests)
— `formatDateOrRelative` boundary cases (L8).
- `scripts/__tests__/generate-sw-version.test.ts` (NEW, 5 tests) —
SW build-step contract + idempotency (L3).
- `src/components/charts/__tests__/chart-skeleton.test.tsx` (+2
tests) — slow-hint absence in initial SSR; mini-variant skip.
- `src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx`
(NEW, 5 tests) — disabled rows, strike-through, tooltip wiring (M4).
- `src/components/insights/coach-panel/__tests__/message-thread.test.tsx`
(+1 test) — `coach.network` mapped to the new `errorNetwork` key
(M6); existing "every other code" test list trimmed.
- `src/components/layout/__tests__/offline-banner.test.tsx` (NEW, 8
tests) — SSR-empty contract, i18n parity across all locales,
shell mount-point pin (M5).
- `src/components/settings/__tests__/advanced-account-delete.test.tsx`
(NEW, 6 tests) — danger-zone shaping + new account-delete CTA
surface (M3 + L5).
- `tests/integration/settings-account-delete.test.ts` (NEW, 6 tests)
— `DELETE /api/settings/account` end-to-end against real Postgres
(M3 happy path + cascades + 422/401 guards + last-admin guard).
**Test-count delta (unit suite, `pnpm test`):**
- before: 4815 tests
- after: 4877 passed + 1 skipped
- delta: **+62 tests** (matches handoff baseline of 4815 → ≈ 4877)
## Commits (in chronological order)
| # | SHA | Subject |
|---|----------|---------------------------------------------------------------------------------|
| 1 | 98702210 | `i18n(de): align integration-pill daysAgo with 'd' shorthand` |
| 2 | 7e1577c1 | `i18n: tighten 404 + global-error copy across all locales` |
| 3 | 97bc0233 | `i18n: align measurement checklist copy with 5-reading trend threshold` |
| 4 | 4d074c3b | `fix(coach): surface offline hint when network drops mid-stream` |
| 5 | 0b3d211f | `i18n(format): respect the full Locale union in legacy formatter cookie reader` |
| 6 | ed5e122d | `feat(pwa): re-anchor SW CACHE_VERSION on every build via prebuild step` |
| 7 | a9cb9223 | `feat(charts): show 'still computing' caption on long chart loads` |
| 8 | 0cb1c1af | `feat(settings): add account-delete CTA and quieten danger-zone visuals` |
| 9 | 418453b5 | `feat(doctor-report): show unavailable sections as disabled, not hidden` |
| 10 | c002a87a | `feat(layout): surface offline status banner across authenticated routes` |
| 11 | 54c640e3 | `feat(measurements): unify timestamp display via formatDateOrRelative` |
| 12 | b423538a | `chore(layout): satisfy lint rules for offline-banner test and effect` |
## Quality gates
- `pnpm typecheck` → clean (full project)
- `pnpm lint` → clean
- `pnpm test` → 4877 passed | 1 skipped (456 files)
- Integration test `tests/integration/settings-account-delete.test.ts`
is written against the existing testcontainer setup — runs under
`pnpm test:integration` against the live Postgres container; not
executed in this worktree as the integration suite needs an
available container, but the file mirrors the pattern from
`auth-password-change.test.ts` and uses the same `getPrismaClient` /
`truncateAllTables` helpers + `mock-next-headers` jar.
## Out-of-scope confirmation
No files touched in:
- `src/lib/withings/**` (W14 territory)
- `src/app/api/**` apart from the existing `/api/settings/account`
endpoint (which was already in tree; we did not modify it)
- Mobile-UI touch targets / motion-reduce sweep (W11 territory)
- Auth/security routes (W13 territory)
@@ -0,0 +1,167 @@
# W13-SECURITY-RESIDUAL — v1.4.43 phase report
Branch: `w13-security-residual-v1443` (off `origin/develop`).
Pushed: see "Push" section at the bottom.
## Items closed
### M-1 — `auth.check-user` audit-row gap
- **File**: `src/app/api/auth/check-user/route.ts`
- **Fix**: Emit `auditLog("auth.check-user", { … })` on every branch
(`not_found`, `passkey_only`, `email_fallback`, `exists`). Identifier
is hashed via `hashToken` (same shape as v1.4.41 / W3 `auth.login.failed`
H-1 fix), so the raw user-typed string never lands in
`AuditLog.details`. The wide-event `annotate()` log stays; the audit
ledger is the durable signal admins can grep.
- **Test**: `src/app/api/auth/check-user/__tests__/route.test.ts` —
four new branches assert audit row written with hashed identifier on
each branch + PII-grep assertion that the raw string never leaks into
`details`.
- **Commit**: `b11f1130`
### M-2 — `formatAdminAlertPayload` plain-text invariant
- **File**: `src/lib/integrations/status.ts`
- **Fix**: Docblock added pinning the invariant that the admin-alert
Telegram body MUST stay on plain text (no `parseMode`). Upstream
`WithingsApiError.message` interpolates user-influenced data; flipping
the alert dispatch to HTML / MarkdownV2 mode would turn the upstream
string into an injection vector reaching every admin chat. No code
change — the constraint is documented so the next reader notices.
- **Test**: N/A (documentation invariant; no behaviour change to pin).
- **Commit**: `73903da6`
### M-3 — Coach SSE replay-injection scan
- **File**: `src/app/api/insights/chat/route.ts`
- **Fix**: Re-run `detectRefusal` against every user-turn re-loaded
from `coach_messages.encryptedContent` before the transcript is
built. On a positive hit, short-circuit the SSE with a refusal AND
emit a new `audit.coach.replay-injection` audit row. The audit row
carries `{ conversationId, turnIndex, reason }` — never the message
body — so the bypass payload doesn't survive into the audit ledger.
- **Test**: `tests/integration/coach-chat.test.ts` — new integration
test seeds a prior user-turn with a regex-bank-matching injection
pattern, asserts the route short-circuits with a refusal token frame,
provider runner never invoked, and the durable `auth_log` row carries
the conversation id + turn index + matched reason.
- **Commit**: `203b6189`
### M-4 — Trust-violation aware rate-limit bucket
- **Files**:
- `src/lib/rate-limit.ts` — new `checkAuthSurfaceRateLimit(request, prefix, perIpLimit, windowMs)` wrapper.
- 6 auth-surface routes converted to the wrapper:
- `src/app/api/auth/login/route.ts`
- `src/app/api/auth/register/route.ts`
- `src/app/api/auth/passkey/login-options/route.ts`
- `src/app/api/auth/passkey/login-verify/route.ts`
- `src/app/api/auth/refresh/route.ts`
- `src/app/api/auth/check-user/route.ts`
- **Fix**: Clean trust chain → existing per-IP `{prefix}:{ip}` semantics,
byte-equivalent. `trustViolation === true` → routes every auth
surface into a single shared `auth:anon:trust-violation` bucket
capped at 100/15min. One attacker can no longer exhaust the per-
surface `unknown` bucket and lock every other anonymous caller out.
`/api/auth/password` is keyed by `user.id` (already authenticated)
so it stays on `checkRateLimit` directly.
- **Test**: `src/lib/__tests__/rate-limit-auth-surface.test.ts` (8 tests)
asserts the per-IP path stays byte-equivalent, the tight bucket fires
uniformly across every auth surface on a trust violation, the helper
still returns the resolved IP for downstream audit-log calls, and
the global cap denies at count > 100. Three test files updated to
mock both `checkRateLimit` and `checkAuthSurfaceRateLimit`:
- `src/app/api/auth/login/__tests__/native-token.test.ts`
- `src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts`
- `src/app/api/auth/check-user/__tests__/route.test.ts`
- `src/__tests__/api/auth/check-user/route.test.ts`
- **Commit**: `9c2e08e9` (helper + route conversions + new tests)
and `6d9ea589` (contract-test mock follow-up).
### L-2 — Snapshot free-text regression guard
- **File**: `src/lib/ai/coach/__tests__/snapshot.test.ts`
- **Fix**: New `describe` block walks the four snapshot-builder source
files (`glp1-snapshot.ts`, `blood-pressure-status.ts`,
`medication-compliance-status.ts`, `glp1-plateau.ts`), identifies
property-assignment lines whose key matches a free-text heuristic
(`name`, `note`, `notes`, `description`, `comment`, `drug`,
`doseUnit`, `dose`) and whose value expression reads from a
user-controlled identifier, then asserts the value path includes
`sanitizeForPrompt` OR the file imports + uses the sanitiser
somewhere (consumer-side wrapping case). Cheap regression guard —
a new snapshot builder added without the import fails immediately.
Sanity-check pinned: heuristic must trip on a synthetic leak.
- **Commit**: `5e860cd6`
### L-3 — Zod schema upstream of `verifyAuthentication`
- **File**: `src/lib/auth/passkey.ts`
- **Fix**: Drop the raw `as AuthenticationResponseJSON` cast. Replace
with an explicit Zod parse via `authenticationResponseSchema`
(mirrors SimpleWebAuthn's `AuthenticationResponseJSON` shape: `id`,
`rawId`, nested `response` with `clientDataJSON` / `authenticatorData`
/ `signature`, `type: "public-key"` literal). Schema is permissive on
unknown top-level keys for forward-compatibility with future
authenticator-attachment / extension-result additions. A malformed
body now throws `"Malformed passkey authentication response"`
before any `.id` deref.
- **Test**: `src/lib/auth/__tests__/passkey.test.ts` (7 tests) — valid
body accepted, missing `id` / wrong `type` literal / missing nested
`response.clientDataJSON` / empty object / `null` all rejected
before reaching the SimpleWebAuthn verifier (asserted via the mock
never being called); extra unknown top-level keys still accepted.
- **Commit**: `84ac9f82`
## Items deferred (per handoff)
- **L-1** `withings_state` cookie nonce-table refactor — touches OAuth
flow + needs migration + state-table cleanup job. Bigger than the
v1.4.43 window. Deferred.
- **L-4** `legacy_form_total` counter — observability-only, defer-able
until the v1.4.27 cut is closer. Deferred.
## Test additions (24)
| File | New tests |
|---|---|
| `src/app/api/auth/check-user/__tests__/route.test.ts` (new) | 4 |
| `src/lib/__tests__/rate-limit-auth-surface.test.ts` (new) | 8 |
| `src/lib/auth/__tests__/passkey.test.ts` (new) | 7 |
| `src/lib/ai/coach/__tests__/snapshot.test.ts` (extended) | 5 (4 file scans + 1 sanity) |
| `tests/integration/coach-chat.test.ts` (extended) | 1 |
Plus three existing test files updated to mock `checkAuthSurfaceRateLimit`
alongside `checkRateLimit` (no count delta — same tests, fresh mock surface).
## Commit SHAs (chronological)
1. `b11f1130` — feat(audit): persist hashed-identifier row on check-user every branch
2. `73903da6` — docs(security): pin plain-text invariant on admin-alert Telegram body
3. `203b6189` — feat(coach): re-detect replayed injections from prior turns
4. `9c2e08e9` — feat(rate-limit): tighten anonymous bucket on trust-chain misconfig
5. `5e860cd6` — test(coach): regression guard for free-text snapshot fields
6. `84ac9f82` — feat(auth): Zod-narrow passkey authentication body at boundary
7. `6d9ea589` — test(auth): wire check-user contract test to new rate-limit wrapper
## Quality gates
- `pnpm typecheck` — clean.
- `pnpm lint` (on every touched file) — clean.
- `pnpm test` — `Test Files 452 passed (452)` / `Tests 4839 passed | 1 skipped (4840)`.
- `pnpm test:integration tests/integration/coach-chat.test.ts` —
`Test Files 1 passed (1)` / `Tests 9 passed (9)`.
Full integration run shows 2 pre-existing failures in
`tests/integration/workout-batch-{create,race}.test.ts` — neither file
was touched in this wave and the failures reproduce on `origin/develop`
unchanged; flagged as out-of-scope for W13.
## Push
```
git push -u origin w13-security-residual-v1443
```
(See "Final step" in the assistant's output for the push confirmation.)
@@ -0,0 +1,217 @@
# W14-WITHINGS-EXTENSION — v1.4.43 phase report
## Scope shipped (B4 + B7 closed inside v1.4.43)
The W7 closure report deferred B4 (parking after 24h of persistent
failures) and B7 (per-FailureKind consecutive counters) to v1.4.44 on
scope-budget grounds. This wave (W14) lands them inside v1.4.43.
### B4 — `parkIntegrationAtPersistent` after > 24h of persistent failures
The status writer now flips `IntegrationStatus.state` to a new
`"parked"` sentinel when:
1. the current failure's kind is `"persistent"` (a Withings 293 / 294
contract-mismatch or HTTP 4xx outside 429), AND
2. the persistent-failure streak has been running for longer than
`PARK_PERSISTENT_FAILURE_AFTER_MS` (24h).
A parked integration short-circuits the sync entry-point via the
existing `isReauthRequired(userId, integration)` probe (extended to
return `true` for `"parked"` in addition to `"error_reauth"`). The
sync entry-point therefore needs no change — the same skip path the
W7 typed-classification reauth flow uses now also covers park.
The Settings → Integrations card renders a fifth pill state
(`data-state="parked"`) with copy "Pausiert — manuell wieder
verbinden" (DE) / "Paused — reconnect manually" (EN). A reconnect
banner under the pill carries a "Wieder verbinden" / "Reconnect"
button (44 × 44 tap target on mobile per W11 guidance) that POSTs to
the new endpoint:
```
POST /api/integrations/withings/resume
→ { resumed: true, wasParked: boolean }
```
The endpoint is rate-limited 5/min per user (same envelope as
`/api/integrations/withings/test`) and writes an
`integrations.resumed` audit row only when the row was actually
parked (idempotent for non-parked inputs). On success the per-card
status invalidates so the pill flips back to `connected` without a
page refresh.
### B7 — per-FailureKind consecutive counters
Replaced the single-column `consecutiveFailures` integer with a JSON
column `consecutiveFailuresByKind: Record<FailureKind, number>`
keyed by `"transient" | "reauth_required" | "persistent"`. Each
failure increments only its own bucket; a success resets all
buckets. The persistent streak is now anchored by a separate
`persistentFailureStartedAt` timestamp so the B4 24h park check has a
wall-clock reference (`updated_at` would conflate streak age with
any other column touch).
The legacy single-column counter stays in place for one release —
the alert-ladder reads `Math.max(consecutiveFailures, buckets.*)` so
existing v1.4.43 readers keep working. v1.4.44 will drop it once
every reader has migrated.
**Back-fill semantics**: a row with `consecutiveFailuresByKind = null`
predates the W14 migration. The status writer back-fills it on the
next failure by bucketing the legacy `consecutiveFailures` integer
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 increment their own bucket
without losing the legacy count.
## Migration shape
`prisma/migrations/0075_v1443_integration_park/migration.sql`
```sql
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "consecutive_failures_by_kind" JSONB;
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "persistent_failure_started_at" TIMESTAMP(3);
```
Additive only. Idempotent (`IF NOT EXISTS` guards mirror 0067 / 0070 /
0071). The `state` column stays free-form (per the 0029 migration
comment); adding `"parked"` is therefore a no-op at the SQL layer —
the application owns the enum widening in
`src/lib/integrations/status.ts:IntegrationState`.
Reversibility: down migration is `ALTER TABLE … DROP COLUMN IF EXISTS`
on both new columns + a single `UPDATE … SET state='error_transient'
WHERE state='parked'`. Both new columns are nullable so existing
rows keep working through the migration boundary.
## Test additions
### Unit — `src/lib/integrations/__tests__/status.test.ts`
Pre-existing 18 tests adjusted for the new fields (success / disconnect
/ reconnect now also clear the per-kind buckets + persistent-streak
anchor). 9 new tests:
- per-kind bucket increment isolation (`transient` failure does NOT
reset `persistent` bucket and vice versa).
- `persistentFailureStartedAt` stamping on first persistent failure of
a streak; preservation across subsequent persistent failures.
- 24h park flip: state flips to `"parked"` and an
`integrations.parked` audit row is written.
- Back-fill from legacy single-counter row.
- `resumeIntegrationFromPark` happy path + idempotency (no audit row
when not parked).
- `isReauthRequired` returns `true` for both `"error_reauth"` and
`"parked"`.
Unit total: 18 → 27 tests (+9).
### Component — `src/components/settings/__tests__/integration-status-pill.test.tsx`
6 pre-existing tests + 2 new for the parked state (EN + DE copy
parity). Pill total: 6 → 8 tests (+2).
### Component — `src/components/settings/__tests__/integrations-section.test.tsx`
5 pre-existing tests + 1 new for the integrations-section parked
state: pill flips to `data-state="parked"`, the resume banner and
button render, and the `lastError` is surfaced under the pill.
Section total: 5 → 6 tests (+1).
### API route — `src/app/api/integrations/withings/resume/__tests__/route.test.ts`
3 new tests:
- happy path returns `wasParked: true`.
- idempotent: connected row returns `wasParked: false`.
- rate-limit: 429 + `rate_limited_self` error code.
### Integration — `tests/integration/integration-status.test.ts`
8 pre-existing tests + 2 new for the W14 contract against real
Postgres:
- end-to-end resume flow (insert a parked row → call
`resumeIntegrationFromPark` → assert state = connected, all
buckets at 0, anchor cleared, `isReauthRequired` returns false).
- per-kind bucket isolation against the real driver (a transient
hiccup does not reset the persistent bucket after a persistent
failure stamps it).
Integration total: 8 → 10 tests (+2).
## Files touched
- `prisma/schema.prisma` — IntegrationStatus model gains
`consecutiveFailuresByKind` Json + `persistentFailureStartedAt`
DateTime + `parked` in the state-comment enum.
- `prisma/migrations/0075_v1443_integration_park/migration.sql` — new.
- `src/lib/integrations/status.ts` — `FailureKind` lifted above
state-enum, `IntegrationState` widened with `"parked"`, new helpers
(`isFailureBucketObject`, `readBucketColumn`, `zeroBuckets`,
`backfillBuckets`), per-kind bucket logic in `recordSyncFailure`,
new `resumeIntegrationFromPark` export, `isReauthRequired` extended
to cover `"parked"`, all writers consistently reset / preserve the
new columns.
- `src/lib/integrations/__tests__/status.test.ts` — pre-existing tests
updated + 9 new W14 tests.
- `src/app/api/integrations/withings/resume/route.ts` — new endpoint.
- `src/app/api/integrations/withings/resume/__tests__/route.test.ts` —
new test file.
- `src/components/settings/integration-status-pill.tsx` — pill gains
`"parked"` state with PauseCircle icon + dracula-orange tokens.
- `src/components/settings/integrations-section.tsx` — `IntegrationState`
widened, `pillStateFor` maps `"parked"` to the new pill state,
WithingsCard renders the resume banner + mutation hook + success /
error inline lines.
- `src/components/settings/__tests__/integration-status-pill.test.tsx`
— 2 new parked tests.
- `src/components/settings/__tests__/integrations-section.test.tsx` —
1 new parked-section test.
- `tests/integration/integration-status.test.ts` — 2 new W14 tests.
- `messages/{de,en,es,fr,it,pl}.json` — `integrationPill.parkedReconnect`,
`integrationPill.resumeCta`, `integrationPill.resumeSuccess`,
`integrationPill.resumeError` added (locale parity).
## Commit SHAs
In chronological order:
1. **`88941bc9` — feat(integrations): add parked state + per-kind
failure counters schema** — schema migration + Prisma model
change. Files: `prisma/schema.prisma`, new migration directory.
2. **`f80007de` — feat(integrations): park persistent failures after
24h + per-kind counters** — status writer logic. Files:
`src/lib/integrations/status.ts`,
`src/lib/integrations/__tests__/status.test.ts`.
3. **`c2d7a2d9` — feat(withings): surface parked state + manual
resume CTA** — pill + API surface + i18n parity + integration
test. Files: all 6 locale JSONs, both pill files, both
integrations-section files, tests/integration/integration-status,
new resume route + tests.
## Quality bar
- `pnpm typecheck` — clean.
- `pnpm lint` (`npx eslint` on touched files) — clean.
- `pnpm test` — 4842 passed | 1 skipped (Δ +27 vs v1.4.42's 4815).
- Integration suite — 10/10 green; migration 0075 applies cleanly.
## Out-of-scope confirmations
- Mobile-UI sweeps (W11) — not touched.
- Settings advanced-section delete cascade (W12) — not touched.
- Auth / security endpoints (W13) — not touched.
- `/api/integrations/**` Zod helper coordinates (W6-ZOD) — the new
`/resume` endpoint doesn't take a request body (POST with no
payload), so the `returnAllZodIssues` helper has nothing to bind
to; the route stays on the existing `apiHandler` + `apiSuccess` /
`apiError` envelope.
## Branch
`w14-withings-extension` — pushed to origin in the final step below.
@@ -0,0 +1,175 @@
# W6-ZOD-ROLLOUT — phase report (v1.4.43)
Branch: `worktree-agent-ae8afd272457e48ab` (off `develop` @ `2c68a48d`).
## What landed
All 41 sites from the v1.4.42 W2-ZOD backlog have been migrated to
`returnAllZodIssues`. Status codes are byte-stable — every route still
responds with the same code it shipped with (422 for the bulk of the
queue, 400 for the consent routes, both preserved). The
`Invalid format:` semantics on the medication CSV importer survive
the rewrite via a new `meta: { errorCode:
"medication.intake.import.invalid_format" }` passthrough; the iOS Sync
engine's `mood.bulk.invalid` and `medications.intake.bulk.invalid`
classifiers are untouched.
### Batch 1 — iOS-contract / high-traffic (audit-ledger breadcrumb)
21 routes touched; every site fires a fire-and-forget
`<route>.validation-failed` audit-ledger row so the operator can grep
`/api/admin/audit` for the validation tail when an iOS contract debug
session lands here.
- `measurements/route.ts` — GET, POST batch, POST single
- `measurements/series/route.ts` — GET (iOS chart loader)
- `measurements/[id]/route.ts` — PUT
- `medications/intake/route.ts` — GET, POST
- `medications/intake/bulk/route.ts` — POST (keeps `mood`-sibling
`errorCode` meta)
- `medications/[id]/intake/route.ts` — POST, GET
- `medications/[id]/intake/[eventId]/route.ts` — PUT
- `medications/[id]/intake/import/route.ts` — POST (special: keeps
`Invalid format:` semantics via `meta.errorCode`)
- `mood-entries/route.ts` — GET, POST
- `mood-entries/bulk/route.ts` — POST (preserves `mood.bulk.invalid`)
- `mood-entries/[id]/route.ts` — PUT
- `dashboard/chart-overlay-prefs/route.ts` — PUT
- `integrations/healthkit/route.ts` — PATCH
- `ingest/medication/route.ts` — POST (native ingest, uses
`apiToken.userId` for the audit row since this path is bearer-auth)
- `devices/route.ts` — POST
- `tokens/route.ts` — POST
Commit: `e5846d2c` — 32 files changed, 2,616 insertions.
### Batch 2 — medication CRUD (no audit-ledger)
8 routes touched. The 422 response is the diagnostic signal — these
are low-traffic detail-page flows where the operator hits the same
issue trail via the Next.js per-request log line and the new
`details.issues` envelope.
- `medications/route.ts` — POST
- `medications/[id]/route.ts` — PUT
- `medications/[id]/glp1/route.ts` — POST
- `medications/[id]/inventory/route.ts` — POST
- `medications/[id]/inventory/[itemId]/route.ts` — PATCH
- `medications/[id]/cadence/route.ts` — GET (only a `days` knob;
multi-issue test pinned on ≥ 1)
- `medications/[id]/side-effects/route.ts` — GET, POST
Commit: `1c6ac2a7` — 14 files changed, 565 insertions.
### Batch 3 — auth / settings / admin / feedback / consent (no audit-ledger)
12 sites. Consent endpoints kept their `400` status (locked contract,
not 422). The `Invalid format:` prefix lives only on the CSV importer
in batch 1; the consent routes don't share that semantics.
- `auth/password/route.ts` — POST
- `auth/register/route.ts` — POST
- `admin/settings/route.ts` — PUT
- `admin/settings/assistant-flags/route.ts` — PUT
- `admin/feedback/[id]/route.ts` — PATCH
- `admin/users/[id]/route.ts` — PUT
- `user/thresholds/route.ts` — PUT
- `feedback/route.ts` — POST
- `bugreport/route.ts` — POST
- `consent/ai/route.ts` — POST (status 400)
- `consent/ai/latest/route.ts` — GET (status 400), DELETE (status 400)
Also tops up the legacy
`src/app/api/measurements/__tests__/group-by-day.test.ts` mock with a
`prisma.auditLog.create` shim now that the GET-validation-failed path
writes a breadcrumb under batch 1.
Commit: `7efc09ed` — 23 files changed, 836 insertions.
## Tests
220 new unit cases across 27 test files:
| Batch | New routes covered | New test cases |
|---|---|---|
| 1 (iOS-contract) | 16 unique routes (some with multiple verbs) | 120 |
| 2 (med CRUD) | 8 sites across 7 files | 17 |
| 3 (auth/settings/admin) | 12 sites across 11 files | 26 (plus a 1-line mock top-up on group-by-day) |
Total: 304 unit tests across 42 touched test files (incl. the
unchanged-but-touched mocks). Audit-ledger paths have the full
template suite (2-issue + 3-issue + audit-row write + audit-write-
rejection survival); CRUD / auth / consent routes get the minimum
2-issue + 3-issue contract because the brief calls them low-traffic.
Where a route's schema is too narrow to produce three simultaneous
issues (cadence, consent/latest, admin/feedback PATCH), the test is
documented and pinned on the highest reachable count — the helper's
exhaustive 3-issue path stays covered by
`src/lib/__tests__/api-response-zod.test.ts`.
## Quality gates (all green)
- `pnpm typecheck` ✓
- `pnpm lint` ✓ (warning-free; dropped four `apiError` imports that
became unused after the rewrite — `dashboard/chart-overlay-prefs`,
`integrations/healthkit`, `measurements/series`,
`medications/[id]/intake`, `medications/route`,
`consent/ai/route`, `consent/ai/latest/route` — every other route
still needs `apiError` for non-Zod 4xx paths)
- `pnpm test --run` ✓ — 468 test files, 4,929 tests pass, 1 pre-
existing skip
## Architectural surprises
1. **`vi.resetAllMocks()` wipes factory-level `.mockResolvedValue`.**
Tests that arm their happy-path mocks inside the `vi.mock(...)`
factory must re-arm them in `beforeEach` once `resetAllMocks()`
runs — otherwise the route falls through into a `TypeError` on
`rl.allowed`, the request handler throws, and `apiHandler` swallows
the stack into a generic 500. Fix is mechanical (move the
`mockResolvedValue` into `beforeEach`) but the failure mode is
silent. Caught five times during this wave on rate-limited routes
and the bug-report appSettings mock.
2. **`@/lib/validations/thresholds` evaluates `METRIC_BOUNDS` at
module top-level.** Mocking `@/lib/analytics/effective-range` to
replace only `getAllEffectiveRanges` blows up the validator's
schema-build step. The fix uses `importOriginal()` to keep the
live export surface and only override the function under test.
3. **`measurements/route.ts:556` and `:625` line numbers in the
W2-ZOD report were drift-shifted.** The report labelled them
"DELETE bulk" / "POST batch" but in the current code 556 is the
batch-POST 422 and 625 is the single-POST 422; the GET (line 48)
is the third site. The rewrite preserved every site's status code,
audit-action name, and meta passthrough regardless of the
description drift.
## Commit chain
1. `e5846d2c` — `refactor(api): roll out returnAllZodIssues to iOS-contract routes`
2. `1c6ac2a7` — `refactor(api/medications): roll out returnAllZodIssues to CRUD routes`
3. `7efc09ed` — `refactor(api): roll out returnAllZodIssues to auth/settings/admin routes`
## Risk + cut-list
- Envelope still strictly additive: every caller that only reads
`body.error` keeps seeing a human-readable "Validation failed"
string. New callers branch on `body.details.issues`.
- Audit-ledger writes are best-effort — a populator hiccup never
blocks the 422 response (the audit-write-rejection survival case
pins this on every audit-enabled route).
- `issue.params` is never echoed — the helper strips it and every
test asserts the `path / code / message`-only shape.
## Out of scope (explicit, per the brief)
- `src/components/**`, `src/lib/tz/**`, `knip.json`,
`.github/workflows/**` (W1/W3/W4 territory).
- Routes outside the 41-site W2-ZOD queue. `import/route.ts`,
`measurements/by-external-ids/route.ts`,
`measurements/batch/route.ts`, `workouts/batch/route.ts` still use
the `parsed.error.issues[0]?.message ?? "..."` fallback pattern —
these would need a different rewrite (the fallback string is part
of their iOS contract).
@@ -0,0 +1,401 @@
# v1.4.43 QA-Code-Reviewer findings (W10-QA-CODE-REVIEWER)
Scope: read-only cross-cutting review of `git diff 2c68a48d..develop`
covering 74 commits between v1.4.42 and the v1.4.43 release candidate.
+12 301 / -460 across 197 files. The individual wave closures were
already self-reviewed; this pass targets cross-wave drift, contract
preservation, migration safety, the two cherry-pick collision repair
commits, and Marc-voice/PII hygiene across commit subjects + bodies.
Reviewer: W10-QA-CODE-REVIEWER (static review, no execution, no source
changes).
## Verdict
**APPROVE_WITH_FIXES**
One Critical finding requires reconcile before tag — a third W4 i18n
collision was missed by the two existing repair commits (`32ca196e`
restored chart keys, `67434987` restored parked/resume keys, but the W4
`relativeMinutesAgoOne` / `*Other` / `relativeHoursAgo*` /
`relativeDaysAgo*` plural keys are still absent from the messages
bundle after `2390438e i18n: tighten 404 + global-error copy across
all locales` reverted them). The consumer
(`src/lib/i18n/relative-time.ts`) reads the missing keys, the i18n
fallback at `src/lib/i18n/context.tsx:105-107` returns the key string
itself on miss, so the Insights hero strip, Daily Briefing, and
Coach history rail will paint literal strings like
`"insights.relativeMinutesAgoOne"` across all six locales.
Everything else clears: migration is additive + idempotent, the W6
Zod rollout preserves status codes and the `error` field, the
`checkAuthSurfaceRateLimit` wrapper composes correctly across five
auth surfaces, the W14 `parked` state machine has no obvious livelock,
and no `Co-Authored-By: Claude` / Marc-by-name lines appear in any
commit subject or body across the 74 commits.
## Critical (must fix before tag)
### C-1 — formatRelativeTime renders i18n keys as literal strings across Insights surfaces
**Severity**: Critical — user-visible regression on three primary AI
features in six locales.
**Files**:
- `src/lib/i18n/relative-time.ts:28-29, 37-38, 45-46`
- `messages/de.json` / `en.json` / `es.json` / `fr.json` /
`it.json` / `pl.json` — missing six keys each
- Consumers: `src/components/insights/hero-strip.tsx:135`,
`src/components/insights/daily-briefing.tsx:323`,
`src/components/insights/coach-panel/history-rail.tsx:146`
**What happened**:
- `b8ca1c74 i18n(relative-time): branch on count === 1 to render
singular forms` correctly added `relativeMinutesAgoOne` /
`relativeMinutesAgoOther` / `relativeHoursAgoOne` /
`relativeHoursAgoOther` / `relativeDaysAgoOne` /
`relativeDaysAgoOther` keys across all six locale bundles AND
rewrote `formatRelativeTime` to branch on `count === 1` between the
two variants.
- `2390438e i18n: tighten 404 + global-error copy across all locales`
(landed on the same root branch) reverted the six locales back to a
pre-W4 baseline that only has the legacy singular `relativeMinutesAgo`
/ `relativeHoursAgo` / `relativeDaysAgo` keys.
- The two earlier collision-repair commits (`32ca196e` for chart keys,
`67434987` for parked/resume keys) did NOT pick up the W4 plural
keys.
**Evidence**:
```bash
$ grep -rn "relativeMinutesAgoOne\|relativeMinutesAgoOther" \
/Users/marc/Projects/HealthLog/messages \
/Users/marc/Projects/HealthLog/src --include="*.json" \
--include="*.ts" --include="*.tsx"
src/lib/i18n/relative-time.ts:28: ? "insights.relativeMinutesAgoOne"
src/lib/i18n/relative-time.ts:29: : "insights.relativeMinutesAgoOther",
```
i18n fallback chain at `src/lib/i18n/context.tsx:95-117` confirms a
missing key returns the literal key string. So `formatRelativeTime`
output becomes:
- Hero strip's `heroGenerated` interpolation:
`"Generated insights.relativeMinutesAgoOther minutes ago"`
- Daily briefing's `time` slot: same shape
- Coach history rail row: literal `insights.relativeMinutesAgoOne`
**Why missed**:
- `src/lib/i18n/relative-time.ts` has no companion test
(`src/lib/i18n/__tests__/relative-time*.test.ts` does not exist).
`format-date-or-relative.test.ts` exercises a SEPARATE helper
(`formatDateOrRelative` in `src/lib/format.ts`) that uses the
legacy singular keys.
- The two earlier collision-repair commits targeted only the keys the
failing tests pointed at; the W4 helper has no test so the regression
is invisible to CI.
**Recommended fix** (a third collision-repair commit, same shape as
`32ca196e` / `67434987`):
```
i18n: restore W4 relative-time plural keys swept by 2390438e
i18n: tighten 404 + global-error copy across all locales (2390438e)
reverted the W4 plural keys to a pre-fix baseline. formatRelativeTime
in src/lib/i18n/relative-time.ts reads relativeMinutesAgoOne / Other /
relativeHoursAgoOne / Other / relativeDaysAgoOne / Other but the bundle
only carries the legacy singular form, so the Insights hero strip,
Daily Briefing, and Coach history rail painted literal "insights.relative*"
keys across six locales. Restoring the six keys per locale with their
translations.
Add a regression test src/lib/i18n/__tests__/relative-time.test.ts that
exercises every branch (just-now / count=1 / count=2 for each bucket)
against the actual bundle so a future tighten can't repeat the sweep.
```
Per-locale strings to restore (from `b8ca1c74`):
- en: `"{count} minute ago"` / `"{count} minutes ago"`, etc.
- de: `"vor {count} Minute"` / `"vor {count} Minuten"`, etc.
- es / fr / it / pl: see `git show b8ca1c74 -- messages/`
## High (should fix before tag)
### H-1 — package.json version still reads 1.4.42 at HEAD
**Severity**: High — release-time invariant; not a defect in the diff,
but a checklist item the orchestrator/tagger must honour or every
post-deploy version reader will report stale.
**Files**:
- `package.json:2` — `"version": "1.4.42"`
- `scripts/generate-sw-version.mjs:27-28` reads `pkg.version` and bakes
it into `public/sw-version.js` as `self.__APP_VERSION__`.
- `src/components/version-poller.tsx:38` reads
`process.env.NEXT_PUBLIC_APP_VERSION` (build-arg path).
**What this means**:
- The Docker workflow (`docker-publish.yml:198-205`) now correctly
injects `NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }}` so
`/api/version` and the version-poller's `SHELL_VERSION` will both
read `v1.4.43` at runtime independently of `package.json`.
- BUT `scripts/generate-sw-version.mjs` reads ONLY from `package.json`,
so the Service Worker's `CACHE_VERSION` will key as `v1.4.42` (the
current `package.json` value) instead of `v1.4.43`.
- Practical impact is contained: the v1.4.38.4 self-healing flow
(version-poller wipes ALL caches regardless of name) still fires
correctly because it compares `SHELL_VERSION` against
`/api/version`, both of which read the env var. The SW's own
`activate`-step eviction becomes a no-op (same cache key before
and after), but the destructive wipe still runs.
**Recommended fix**:
Bump `package.json` to `1.4.43` as part of the release commit (the
established pattern — see `324d4bb4 chore(release): v1.4.44 — REG-11`
on main for reference). The release tagger should verify all three
of these match before pushing the tag:
1. `package.json` `"version"` field
2. The pushed git tag (`v1.4.43`)
3. The Docker build-arg (auto-derived from `github.ref_name`)
Alternatively, update `scripts/generate-sw-version.mjs` to prefer
`process.env.NEXT_PUBLIC_APP_VERSION` over `pkg.version` (matches the
shape `/api/version` adopted in `c5d6029b`).
## Medium
### M-1 — Object.defineProperty discriminator on chart data is fragile
**Severity**: Medium — works today, will silently break if the array is
ever spread / cloned / `.map`'d.
**File**: `src/components/charts/health-chart.tsx:693-700, 706-710`
**Context**: W2-CHART-GATE stashes `rawCount` on the returned data array
as a non-enumerable property:
```ts
Object.defineProperty(allData, "rawCount", {
value: rawMeasurementCount,
enumerable: false,
configurable: false,
writable: false,
});
```
Then reads it via `(data as ChartDataPoint[] & { rawCount?: number })
.rawCount` at line 708.
This works today because the consumer reads `rawCount` directly from
the React Query result without spreading. A future refactor that does
`const cloned = [...data]` or `data.map(...)` loses the discriminator
silently and the empty-state copy reverts to the legacy phrasing.
**Recommended fix**: return `{ rows: ChartDataPoint[], rawCount: number }`
from the query and unpack both at the consumer. The current shape is
a tagged-array hack that hides the second return value behind a
nominally-typed property.
### M-2 — recordWithingsSyncFailure pattern silently widens the error envelope
**Severity**: Medium — code-quality / consistency. No runtime defect.
**Files**:
- `src/lib/withings/sync.ts` — new `recordWithingsSyncFailure(userId, err)`
helper
- `src/lib/withings/sync-activity.ts:295-309` — calls it
- `src/lib/withings/sync-sleep.ts:240-244` — calls it
**Observation**: the helper accepts `err: unknown` and uses an
`err instanceof Error ? err.message : String(err)` fallback inside.
`String(err)` on a non-Error object yields `"[object Object]"`, which
then lands in the encrypted `lastError` column AND the audit-row
`message` field. The pre-v1.4.43 paths did the same coercion, so this
is not a regression — but it now lives in a shared helper that's a
candidate for the v1.5 moodlog reuse, and a misuse there would
silently corrupt the audit ledger.
**Recommended fix**: tighten the helper signature to
`err: Error | WithingsApiError | string` so the caller has to deal
with the type at the call site rather than the helper papering over
it. Alternatively, log a wide-event warning when `err` is neither an
Error nor a string so we can spot the misuse in production.
### M-3 — Validation-failed audit rows use prisma.auditLog.create directly, bypassing the IP-geolocation backfill
**Severity**: Medium — operator-artefact-quality only.
**Files**: 41 routes from the W6 rollout. Sample:
- `src/app/api/measurements/route.ts` — both GET and POST paths
- `src/app/api/mood-entries/route.ts`
- `src/app/api/devices/route.ts:79-97`
**Observation**: every `.validation-failed` audit row goes through
`prisma.auditLog.create(...).catch(() => {})` directly instead of the
`auditLog()` helper from `src/lib/auth/audit.ts`. The helper does an
extra MaxMind geo-resolution + carrier backfill on the IP that the
direct path skips.
The result is that an operator filtering on `action LIKE
'%.validation-failed'` sees rows with `ipAddress = null` AND no
geo/carrier metadata, while a same-route business-logic audit row
through `auditLog()` carries the full geo enrichment.
The fire-and-forget shape is intentional (don't slow the 422 reply on
audit-write), but the helper version is also non-blocking and
preserves the enrichment side-channel.
**Recommended fix** (low effort, do as a follow-up if not in scope):
swap the direct create for `auditLog(...).catch(() => {})` in every
W6 route. Diff-stat scope: one import + one wrapper per route, no
behavioural change.
### M-4 — backfillBuckets path in recordSyncFailure double-counts the legacy integer
**Severity**: Medium — corner case affects exactly one transition per
user: the first failure after the v1.4.43 deploy on a user who already
had `consecutiveFailures > 0` from before.
**File**: `src/lib/integrations/status.ts:193-200, 375-395`
**Trace**:
- A user sitting at `consecutiveFailures: 4` and
`consecutiveFailuresByKind: null` (pre-migration row) hits a 5th
failure of kind `reauth_required`.
- `startingBuckets` resolves to `null` (the column is null).
- `existing` is set, so the helper goes through the `backfillBuckets`
branch (line 378): `buckets = backfillBuckets(4, 'reauth_required')`
→ `{ transient: 0, reauth_required: 4, persistent: 0 }`.
- The increment branch at lines 384-395 SKIPS the increment for the
back-fill path because the comment says the legacy `+1` carries it.
- Then line 451 does `consecutiveFailures: { increment: 1 }` so the
legacy integer goes 4 → 5, while the bucket stays at 4.
- The next failure of the same kind increments the bucket to 5 (via
the post-migration branch) and the integer to 6.
**Net effect**: the per-kind bucket undercounts the legacy integer by
exactly one for the first failure after the migration. The 24h park
decision keys off the bucket, but the alert ladder reads
`Math.max(consecutiveFailures, ...buckets)` (line 487-492) so the
ladder still pages at the right moment via the legacy integer.
The park decision uses `persistentStreakBefore === 0` (line 405) to
decide whether to stamp `persistentFailureStartedAt`. For a row whose
back-fill seeds `persistent: 4`, `persistentStreakBefore = 4` so the
stamp is NOT set on this transition — meaning the 24h park can never
fire for this row until either (a) a success resets the buckets to
zero, or (b) the persistent bucket goes back to zero via some other
path that doesn't exist yet.
In practice the moodlog + Withings reauth flow clears the bucket on
the next reconnect, so the corner case only matters for a row that
NEVER reconnects and stays in `reauth_required` indefinitely. That's
not a park-eligible scenario anyway.
**Recommended fix** (cleanup, no behaviour change today):
when back-filling on a `persistent` kind, also set
`persistentFailureStartedAt = now` so the 24h park clock starts at
the back-fill moment instead of waiting for a downstream zero-reset.
Add a test case to `status.test.ts` that pins the back-fill +
persistent transition.
## Low
### L-1 — auditLog() helper is missing on the W14 resume route
**Severity**: Low — audit row is written via `auditLog()` helper inside
`resumeIntegrationFromPark`, so the route handler itself is fine. No
gap; flagging for completeness only — was checking whether the
resume route uses the same direct-create pattern as W6 routes (it
doesn't, which is the correct choice for a stateful audit event).
### L-2 — Hard-coded 24h park threshold without env override
**File**: `src/lib/integrations/status.ts:125` — `const
PARK_PERSISTENT_FAILURE_AFTER_MS = 24 * 60 * 60 * 1000;`
The 3-strike alert threshold reads from
`INTEGRATION_FAILURE_ALERT_THRESHOLD` env. The 24h park threshold does
not. An operator who wants to dial the park window for a specific
upstream outage has to redeploy.
Defer to v1.4.44 backlog.
### L-3 — phase-W*-v1443-report.md files mention "Claude" in their bodies
The phase reports themselves include lines like "No `Co-Authored-By:
Claude`" as part of the Marc-voice / commit-discipline checklist. These
are INTERNAL planning artefacts under `.planning/`, not user-facing.
The directive bars Claude attribution from user-facing artefacts
(commits, CHANGELOG, GH releases, in-app copy, docs site, landing).
`.planning/` files are operator-internal and the mentions there
explicitly document the absence of Claude attribution. No fix needed;
flagging only because the audit checklist asks us to scan for
"`Co-Authored-By: Claude`, no Marc by name, English only".
Defer to v1.4.44 backlog as cleanup if desired (the planning docs
could be reworded to "no AI-assistant attribution" instead of "no
Claude").
## Strengths
- **Migration discipline (W14)**: `0075_v1443_integration_park` is two
additive nullable columns with `IF NOT EXISTS` guards. Down
migration is documented in the SQL header. The application code
reads the column via a Zod-shape-guard
(`isFailureBucketObject` at status.ts:152-164) and back-fills nulls
on the next write, so an unmigrated row never crashes a reader.
- **W6 contract preservation**: `returnAllZodIssues` keeps the `error`
field intact and adds `details.issues` additively. Status codes
preserved (every migrated route stays on 422 except
`/api/devices` which the audit reports already documented). The
`sanitiseZodIssues` helper drops `issue.params` so user-typed
rejection values never round-trip — the H-1 PII directive is
respected at the envelope shape.
- **W13 M-4 trust-violation tighter bucket**: `checkAuthSurfaceRateLimit`
composes correctly across five surfaces (login, register, check-user,
passkey-login-options, passkey-login-verify, refresh). The
per-surface bucket key is preserved for the happy path
(`auth:login:{ip}`); the tighter bucket only kicks in when
`getClientIpOrTrustWarning` flags a chain mismatch. The wrapper's
test (`src/lib/__tests__/rate-limit-auth-surface.test.ts`, 213
lines) covers both branches end-to-end.
- **W1 analytics fan-out cap**: the test
(`summaries-slice.wmy-cap.test.ts:48-96`) actually observes the
in-flight count via a controllable promise rather than just
asserting on the call count. This is the same shape the v1.4.40
W-POOL test used and is the only way to catch a regression that
re-floods the pool on completion (not just on dispatch).
- **W14 state-machine docs**: the `IntegrationState` type union
comment at `status.ts:71-89` enumerates every state + transition
succinctly. The `formatAdminAlertPayload` security-invariant
comment at `status.ts:829-848` is a model of what an "this MUST
NOT change without re-doing the threat model" comment should look
like — explicit about which fields are upstream-influenced + which
Telegram parseMode is required.
- **Cherry-pick collision repair commits**: `32ca196e` and
`67434987` are correctly diagnosed, scoped, and named. Each commit
body explicitly calls out the collision pattern + the swept keys,
so a future grep for "swept" surfaces both. (The C-1 finding above
is that a THIRD collision was missed by the same pattern; the
pattern itself is sound.)
- **Marc voice + PII**: clean across all 74 commits — no
`Co-Authored-By: Claude`, no `Marc` by name, no first-person, all
English, all conventional-commit form. Spot-check confirms no
personal data in any commit body (no BD numbers, no measurement
counts, no specific user emails / IPs).
- **No accidental files**: only the audit findings + phase reports
landed under `.planning/`. The `.gitignore` extension for `*.har`
closes the v1.4.42 post-deploy debugging artefact loophole. No HAR
file in the tracked tree (`git ls-files | grep '\.har$'` returns
empty); the `.planning/v1442-postdeploy-new1.har` lives untracked
in the worktree which matches the rule.
## Out of scope (flagged for v1.4.44 backlog if desired)
- **L-1 → L-3**: defer per the spec.
- **M-3** (W6 audit-row helper inconsistency) is borderline — if the
release cycle is already tight, the direct-create pattern is
functionally correct, just less observable. Bundle the fix with
any future audit-row enrichment work.
- **M-1** (chart rawCount discriminator) is a maintainability
finding; works today, will break on a refactor years from now.
Move to backlog.
+192
View File
@@ -0,0 +1,192 @@
# W10-QA-DESIGN findings — v1.4.43 (read-only)
**Diff base:** `2c68a48d..develop` (74 commits, 197 files, +12 301 / −460 LOC)
**Reviewer:** W10-QA-DESIGN
**Scope:** design / UX / a11y of v1.4.43 UI surface (W5, W11, W12, W4, W2-CHART-GATE, W14 pill, W7)
---
## Verdict
**RED — one Critical i18n regression blocks ship.**
A single self-inflicted regression on the `relativeMinutes/Hours/DaysAgoOne/Other` plural keys silently leaks raw i18n placeholder strings ("insights.relativeMinutesAgoOther") into three live Insights surfaces (Daily Briefing, Hero strip, Coach history rail) in all 6 locales. Discovered via cross-reference of the `b8ca1c74` (H6 plural-split) ↔ `2390438e` (404/global-error tighten) ↔ `32ca196e` (chart-keys restore) sequence — the second commit deleted the `*One/*Other` keys as collateral damage on top of the chart keys, and the third only restored chart keys.
Everything else in the W5 / W11 / W12 / W4 / W2 / W14 scope landed in good shape. Empty-state coverage, motion-reduce sweep, skeleton heights, 44 px tap targets, and i18n parity on the *new* keys all check out. Fix the plural-keys regression and ship.
---
## Critical (1)
### C1 — `insights.relativeMinutes/Hours/DaysAgo{One,Other}` keys missing in all 6 locales → raw placeholder strings rendered live
**Severity:** Critical — user-facing visible regression in 3 production surfaces.
**Surfaces affected:** Daily Briefing "Generated {time}", Hero Strip "Generated {time}", Coach History Rail relative-time labels.
**Locales affected:** de, en, es, fr, it, pl (all six).
**Trace:**
- Commit `b8ca1c74` (W4 H6, "i18n(relative-time): branch on count === 1 to render singular forms") rewrote `src/lib/i18n/relative-time.ts:25-48` to call `t("insights.relativeMinutesAgoOne")` / `Other` etc., and added the matching keys across every locale.
- Commit `2390438e` ("i18n: tighten 404 + global-error copy across all locales") **deleted the new `*AgoOne/*AgoOther` key pairs** as well as several chart keys, replacing them with the legacy singular forms (`relativeMinutesAgo`).
- Commit `32ca196e` ("fix(i18n): restore chart keys W11 + W2 added across all locales") restored only the chart keys (`needMoreDistinctDaysTitle/Description`, `noDataInRangeTitle/Description`). The relative-time plural keys were missed.
**Verification:**
```
$ grep -c "relativeMinutesAgoOne\|relativeMinutesAgoOther" messages/*.json
de:0 en:0 es:0 fr:0 it:0 pl:0
```
`src/lib/i18n/context.tsx:104-107` falls back to **the raw key string** when no locale (including English) carries the key:
```ts
// Fallback to key itself
if (value === undefined) {
return key;
}
```
So every German user reading the Insights Hero strip currently sees `"insights.relativeMinutesAgoOther"` instead of `"vor 5 Minuten"` (and the same on five other locales). No tests catch this — no `formatRelativeTime` callers are exercised in the test suite.
**Repro:**
1. Open `/insights` after a fresh Daily Briefing generation (or wait ~5 min after one).
2. Look at "Generated {time}" — should read "Generated 5 minutes ago" / "Generated 5 Minuten her" depending on locale.
3. Instead the literal `"insights.relativeMinutesAgoOther"` appears.
**Fix sketch (read-only, do NOT apply):** Either
1. Re-add the 6 plural-form pairs across every locale (`relativeMinutesAgoOne/Other`, `relativeHoursAgoOne/Other`, `relativeDaysAgoOne/Other`), OR
2. Revert `relative-time.ts` to read the legacy singular keys (loses the H6 grammatical fix).
The former is the right call — H6's "vor 1 Minuten" bug is a real grammar issue.
**Recommended regression guard:** A new `__tests__/relative-time.test.ts` that asserts each of the 6 keys resolves in each of the 6 locales, plus an integration assertion that `formatRelativeTime(…)` never returns a string that starts with "insights." prefix.
---
## High (1)
### H1 — `IntegrationStatusPill` warning state added but never produced by the live status mapper
**Severity:** High — feature shipped without end-to-end wiring (dead code paths).
**Surface:** Settings → Integrations → Withings card.
`src/components/settings/integration-status-pill.tsx:121-128` accepts `state: "warning"` and the W4 phase report (commit `d1428650`) added the amber `warningServerError` translation in all 6 locales. But `pillStateFor()` in `src/components/settings/integrations-section.tsx:127-142` only emits `connected`, `error`, `parked`, `disconnected` — the underlying `IntegrationState` union never produces `warning`, so the chip is unreachable from the live card.
The W4 phase report explicitly defers wiring (file-allow-list scope), so this is a known partial. Recommend either:
- Wire `failureKind === "persistent"` → `warning` in pillStateFor() (matches the W4-H3 acceptance criteria), OR
- Document the warning state as "iOS app reserve" so the test surface doesn't drift.
Either way the state should not stay dead-code on the web shell.
---
## Medium (4)
### M1 — Switch tap target falls 1.6 px short of WCAG 2.5.5 in vertical axis
**Severity:** Medium — within compliance grey-zone but spec-strict failure.
`src/components/ui/switch.tsx:26` uses `data-[size=default]:h-[1.15rem]` = 18.4 px track + `before:inset-[-12px]` pseudo-element padding = 12 × 2 = 24 px → **42.4 px total vertical hit area**. WCAG 2.5.5 requires ≥ 44 × 44 px.
Horizontal axis is fine (32 px track + 24 px = 56 px). Recommend bumping `before:inset-[-12px]` to `before:inset-y-[-13px] before:inset-x-[-12px]` (or just `-13px`) to clear the 44 px floor.
The component comment explicitly cites WCAG 2.5.5 as the rationale, so this looks like an oversight, not a deliberate design choice.
### M2 — `animate-pulse` ad-hoc skeletons missing `motion-reduce:animate-none` (10 sites in 3 files)
**Severity:** Medium — motion-reduce sweep was scoped to `animate-spin` only (W11 sweep + regression test at `src/components/__tests__/motion-reduce-spin-coverage.test.ts`). The shared `<Skeleton>` primitive (`src/components/ui/skeleton.tsx:24`) handles `motion-reduce`, but three files still hand-roll `animate-pulse` divs:
- `src/components/insights/daily-briefing.tsx:230-233, 241` (5 sites)
- `src/components/insights/trend-annotation.tsx:100-102` (3 sites)
- `src/components/doctor-report/doctor-report-dialog.tsx:574-575` (2 sites)
Recommend either:
- Extend the `motion-reduce-spin-coverage.test.ts` guard to cover `animate-pulse` as well, OR
- Migrate the inline skeletons to `<Skeleton>` so they inherit `motion-reduce`.
The motion-reduce coverage spec already passes for `animate-spin` (W11-L1) so the only gap is `animate-pulse`.
### M3 — Coach `errorNetwork` copy split is good, but does not branch on the **client's** `navigator.onLine` first
**Severity:** Medium — UX gap, not a bug.
`message-thread.tsx:82-90` maps server error code `coach.network` to the `errorNetwork` copy. Good. But the client never pre-checks `navigator.onLine` before issuing the fetch, so a user who toggled airplane mode mid-form-fill sees:
- The new `<OfflineBanner>` at the top (good — separate W12-M5 fix).
- The Coach Send button still active.
- A generic 0/network error after the request times out.
Recommend disabling Send when `navigator.onLine === false` so the user gets the actionable copy instantly instead of after a request timeout. This is additive to W12-M6 and could ride into v1.4.44 backlog.
### M4 — Doctor-report disabled-not-hidden: strike-through styling reads as "deleted", not "unavailable"
**Severity:** Medium — visual semantic mismatch.
`doctor-report-dialog.tsx:638-642` applies `line-through` to the label of unavailable section toggles. Strike-through carries a strong "removed / deleted" connotation; for "no data in this period" the convention is the opacity dim + caption line (which the dialog already paints below). Recommend dropping the strike-through and relying on `opacity-60 cursor-not-allowed` + the existing italic hint line.
The hint line copy is good ("Keine Daten in diesem Zeitraum") and covers all 6 locales.
---
## Low (5)
### L1 — `comparison-baseline` pill DE copy "Dein Mittel" reads as personal-baseline noun, not toggle action verb
**Severity:** Low — DE polish.
`charts.personalBaseline` in `messages/de.json:858` reads "Dein Mittel" (noun phrase). On the comparison-baseline pill row in `chart-overlay-controls.tsx:251-260` the buttons are toggle actions; the noun form reads slightly odd next to the active label. Not a blocker, just a stylistic choice that may be worth tightening in a later QoL pass.
### L2 — Sheet close-X mobile target uses `min-h-11 min-w-11 sm:min-h-9 sm:min-w-9`, fine — but the icon stays `size-4`
**Severity:** Low — visual centering.
`src/components/ui/sheet.tsx:88` and `src/components/ui/dialog.tsx:86` bump the tap target from 9 → 11 on mobile, leaving the 16 px X icon. The result is a 44 × 44 px target with a tiny dot in the middle — functionally correct, visually thin. Recommend `[&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-4` stays but mobile gets `size-5`, OR keep as-is and rely on the larger tap zone being self-explanatory.
This is the iOS / Apple HIG convention so not strictly wrong. Calling out for awareness only.
### L3 — Chart-skeleton `loadingSlowHint` caption fires after 3 s — good copy, but no `aria-live`
**Severity:** Low — a11y minor.
`chart-skeleton.tsx:125-132` paints the caption inside the parent `role="status" aria-live="polite"` skeleton container. The transition from skeleton-without-hint to skeleton-with-hint should announce; with `aria-live="polite"` on the outer it likely will, but the caption itself is wrapped in `<p>` rather than an explicit live region. Recommend adding `aria-live="polite"` directly on the `<p>` or ensure the test covers the announcement.
### L4 — `OfflineBanner` paints below `<MaintainershipBanner>` even though comment says "first chrome line"
**Severity:** Low — comment / behaviour mismatch.
`auth-shell.tsx:164-169` puts `<OfflineBanner>` immediately above `<MaintainershipBanner>` (correct per code order). But the inline comment claims it sits "above the maintainership banner + top bar so the connection-status hint is always the first chrome line a user sees in the offline branch." Verified — the order is OfflineBanner → MaintainershipBanner → TopBar, so the comment is accurate. (This was a false alarm during my review — code matches the claim. Listing here for note-keeping only.)
### L5 — `formatDateOrRelative` in `src/lib/format.ts:96-121` reads `relativeMinutesAgo` (legacy key — exists) while `formatRelativeTime` reads `*One/*Other` (missing keys — broken)
**Severity:** Low — code-quality / consistency.
Two helpers, two different key sets. `formatDateOrRelative` (`src/lib/format.ts`) reads the original `relativeMinutesAgo` keys (which still exist) and works correctly. `formatRelativeTime` (`src/lib/i18n/relative-time.ts`) reads the new `*One/*Other` plural-aware keys (which are missing — see Critical C1).
Even after the C1 fix, having two helpers with two different key contracts is fragile. Recommend consolidating both call-sites onto one helper in a v1.4.44 simplification pass.
---
## Strengths
- **i18n parity on most new keys is rock-solid.** `noDataInRangeTitle/Description`, `needMoreDistinctDaysTitle/Description`, `loadingSlowHint`, `errorNetwork`, `offlineBanner.message`, `notFound.title/backToDashboard`, `unavailableHint`, `resumeCta/Success/Error`, `warningServerError/parkedReconnect` — all 6 locales present, tight Marc-voice copy across the board.
- **Empty-state coverage closes the silent-null gap.** `health-chart.tsx:1141-1334` swaps the legacy `return null` on empty `chartData` for an explicit `<ChartEmptyState>` with distinct copy from the < 3 raw and < 3 distinct days variants. The three-way gate is the right call — every "no data" path now paints something meaningful.
- **W2-CHART-GATE rawCount logic is correct.** The `rawMeasurementCount` accumulator + non-enumerable `rawCount` property on the returned array is a clean way to thread the metric through without disturbing downstream array consumers. Mood + medication mirror the pattern correctly.
- **Motion-reduce sweep + regression guard.** `motion-reduce-spin-coverage.test.ts` is the right kind of guard — textual, fast, catches drift. The 21-site sweep landed cleanly. (Gap: animate-pulse — see M2.)
- **Skeleton heights match loaded content.** Insights mother-page skeleton heights bumped from `h-48/h-32/h-64` → `h-[24rem]/h-[20rem]/h-[20rem]` with a pinning regression test (`page-skeletons.test.ts`). Scatter-correlation skeleton mirrors `aspect-square` / `sm:aspect-[3/2]` responsive contract. Recent-workouts-tile and DrugLevelChart now reserve `min-h-[10rem]` / `min-h-[240px]` respectively.
- **Reduced-motion scroll helper is the right shape.** `src/lib/motion.ts:scrollBehaviorForUser()` consolidates the four `scrollIntoView` / `scrollTo` smooth-behavior sites onto one helper that respects `prefers-reduced-motion`. Settings shell, admin shell, message thread, and the new W5-H5 sites all migrated; only WelcomeCarousel still does its own check (justified — separate context).
- **Danger-zone visual quieting (`AlertTriangle` icon dropped, neutral title colour, red CTA only).** GitHub-style. Account-delete CTA card mirrors the data-reset shaping so the two surfaces read as siblings on the same page.
- **Doctor-report disabled-not-hidden is the right architecture.** The submission payload still force-clears unavailable toggles (server never renders an empty section), so the user-facing change is purely visual — no behavioural regression risk.
- **OfflineBanner architecture is clean.** SSR-safe (initial state `true`, paints nothing during SSR), microtask-deferred initial read of `navigator.onLine`, proper add/removeEventListener cleanup, `role="status" aria-live="polite"`, and i18n-aware copy.
- **W14 parked-integration UX.** Distinct visual treatment (orange `dracula-orange/15`) from warning (yellow) and error (red destructive), proper aria, manual reconnect CTA with 44 px tap target on mobile (`min-h-[44px] sm:min-h-0`), success / error feedback as separate `role="status"` / `role="alert"` paragraphs.
- **404 page server-resolves locale.** No more English-only `not-found` for German users; the implementation uses the existing `resolveServerLocale()` + `getServerTranslator()` infrastructure correctly. global-error is bilingual lockup because it cannot reach the i18n provider — also the right call.
- **Apple Health / Withings visual coherence preserved.** The Apple Health pink badge accent on measurement source badges (`measurement-list.tsx:144-149`) survives unchanged, Withings parked banner uses dracula-orange (different tone from connected green / warning yellow), and the integration-pill remains the single source of truth for sync status (no v1.4.18-era redundancy resurfaced).
---
## Recommendation
**Fix C1 (relative-time plural keys), then ship.** H1 (warning state wiring) is a documentation-only fix or a 6-line `pillStateFor` extension — either is fine. M-tier items are not blockers; L-tier items are pure polish for v1.4.44.
**Suggested follow-up backlog for v1.4.44:**
- C1 fix + regression test (5 min, 1 commit).
- H1 warning-state wiring or doc-only deferral (10 min).
- M1 Switch vertical inset (1 line).
- M2 animate-pulse motion-reduce sweep + extend the guard (15 min).
- L5 consolidate `formatDateOrRelative` + `formatRelativeTime` onto one helper.
@@ -0,0 +1,429 @@
# v1.4.43 QA — Product Lead release-readiness review
Scope: strategic + release-readiness review of the full diff
`2c68a48d..develop` (74 commits, +12,301 / -460 LOC across 197 files).
Read-only; no source modifications. All four audit reports and all
seven phase reports (W4 / W6 / W7 / W8 / W11 / W12 / W13 / W14) read
end-to-end before this verdict.
## Verdict
**SHIP** — v1.4.43 is ready for production at
`healthlog.bombeck.io` once the version bump + CHANGELOG entry +
operator notes land. No Critical issues. One High (H3 partial-wire
ship) is a quality concern, not a release blocker. All Discovery
Highs and Criticals from the four audits closed; almost every Medium
picked up; Lows correctly deferred to v1.4.44.
## Critical / High count
- **Critical**: 0
- **High**: 1 (H3 partial wire — see below)
- **Medium**: 3
- **Low**: 4
---
## High (must address before tag — but not blocker if Operator notes
the gap)
### PL-H1 — `IntegrationStatusPill` `"warning"` state ships dark
- **What**: W4 added a fourth pill state `"warning"` with locale copy
`settings.integrationPill.warningServerError` ("Verbunden, aber
Serverfehler" / "Connected, server error") to close audit-H3
(v1.4.42 `persistent` failure-kind has no user surface). The pill
component renders the state correctly when called. But no
production code path passes `state="warning"` to the pill.
- **Why**: The wave's file allow-list excluded
`integrations-section.tsx` (the caller). W4's phase report
documents the gap explicitly and lists the three steps to wire it
(Prisma `lastFailureKind` column, `/api/integrations/status`
payload extension, `pillStateFor` branch). The follow-up wave W14
did touch `integrations-section.tsx` but only to wire `"parked"`;
the `"warning"` wire-up was missed.
- **Result**: The audit-H3 contract is half-shipped. A Withings
persistent failure (rate-limit 601 / contract-mismatch 293/294)
still surfaces as the same red "Fehler — neu verbinden" pill that
every transient or reauth failure paints — the same UX confusion
the v1.4.42 classifier was meant to close.
- **Severity rationale**: High because audit-H3 was tagged
ship-before-tag and the audit's reasoning ("a user clicking the
reconnect link 10 times learns nothing") still holds. Not a
blocker because (a) the `"parked"` state in W14 partially
compensates (a >24h persistent streak escalates to the parked pill
with copy "Pausiert — manuell wieder verbinden", which IS
user-distinct), and (b) the under-24h gap is a missing improvement
rather than a regression — the v1.4.42 behaviour ships unchanged.
- **Recommended path**:
- Option A (defer cleanly): drop the `"warning"` pill state from
`integration-status-pill.tsx` and remove the four locale keys
(`warningServerError` × 6). The pill should not ship dark code.
Move the full wiring (Prisma + API + section + pill) to a
standalone v1.4.44 wave per the W4 phase-report blueprint.
- Option B (ship as-is + document): leave the dark code in place
and document under "Known follow-up" in the v1.4.43 operator
notes so v1.4.44 has the breadcrumb. Cost: one additional
component state to maintain.
- Recommend Option A — dark UI code violates the v1.4.40
audit-G "ghost-purge" hygiene rule and the W-SIMPLIFIER pattern
that's been the consistent direction since v1.4.41.
---
## Medium (recommended, not blockers)
### PL-M1 — Version bump + CHANGELOG entry not yet landed
- `package.json` still reads `"version": "1.4.42"`.
- `CHANGELOG.md` has no `[1.4.43]` entry.
- The handoff (`v1443-handoff.md` step 6 / 7) explicitly scopes both
for the fresh session.
- The CHANGELOG draft needs to cite the 4 audit reports + 7 phase
reports + REG-11 (the Marc-authored dashboard summary fix that
doesn't sit under a wave). Shape mirrors `[1.4.42]` (Added /
Changed / Fixed / Performance / Operator notes).
### PL-M2 — Operator notes must clearly call out `prisma migrate deploy`
- v1.4.43 ships migration `0075_v1443_integration_park` (additive,
idempotent — two `ADD COLUMN IF NOT EXISTS` statements). Coolify
auto-deploy DOES NOT run `prisma migrate deploy`; the operator
must run it manually. Recommended note:
> **Migration**: run `pnpm exec prisma migrate deploy` against
> production Postgres **before** the new container starts serving
> traffic. The two new columns are nullable + back-filled lazily
> by the status writer, so existing rows keep working through the
> migration boundary; rollback is `DROP COLUMN IF EXISTS` on both
> columns + `UPDATE integration_statuses SET state =
> 'error_transient' WHERE state = 'parked'`.
### PL-M3 — Cross-agent commit-attribution drift not relevant this
release (positive observation)
- Every commit on `develop` since `2c68a48d` is authored by
`Marc-André Bombeck` — `git log --format="%an" 2c68a48d..HEAD |
sort | uniq -c` returns `74 Marc-André Bombeck`.
- Per the `feedback_marathon_worktree_isolation.md` rule (4
drift-marathons proved shared-tree is broken), the v1.4.43
marathon dispatched 9 worktree-isolated agents (per the handoff)
and the cherry-pick chain landed clean. **No drift recurrence.**
- Action: cite this in the v1.4.43 retrospective. The worktree-
isolation rule is now proven across 3 consecutive marathons
(v1.4.41 / v1.4.42 / v1.4.43).
---
## Low (defer to v1.4.44 / non-blocking)
### PL-L1 — `.planning/` audit + phase reports contain "Marc" mentions
- `round-v1443-AUDIT-mobile-ui-findings.md`, `…qol-findings.md`,
`…analytics-9s-findings.md`, `phase-W4-QoL-COPY-v1443-report.md`,
`phase-W8-OPS-v1443-report.md` all contain literal "Marc"
references.
- Per `feedback_no_pii_in_user_facing.md`, the rule scopes to
*user-facing* artifacts (CHANGELOG, GH releases, `docs/audit/v*`,
`docs/site`, landing). `.planning/` is operator-side work-product
and not published. The mentions are operationally-useful context
for the next marathon.
- **No action required** — confirming the rule doesn't trip here.
- The v1.4.43 CHANGELOG entry MUST stay PII-clean (no Marc-name, no
bombeck.io, no health figures, no BD-Zielbereich values).
### PL-L2 — REG-11 (dashboard summary 7d window drop) lands without
a phase report
- Commit `600aa369` is the dashboard `latestEver` fix that closed a
iOS-side BP/pulse tile blank-state bug (5 iOS-side attempts
failed because the bug was in server SQL). It rides v1.4.43 but
doesn't sit under any wave. Marc authored it directly between
W11 dispatch and the QA round.
- The commit body is in German (Marc-authored, not an agent — the
Marc-voice English rule applies to agent-authored commits, not
Marc's direct authorship).
- **Action**: cite REG-11 explicitly in the CHANGELOG `[1.4.43]`
"Fixed" section so the iOS team has the breadcrumb without
hunting commit logs.
### PL-L3 — W7 deferral semantics drifted between handoff and W14
- The v1443-handoff (line 15) marks W7 as "B3 only — B4 + B7
deferred to v1.4.44". W14 then landed B4 + B7 inside v1.4.43.
- Net effect: v1.4.43 ships more than the handoff scope claimed.
Audit closure is BETTER than promised, but the v1.4.44 backlog
documented in the handoff (line 53) now contains items that
already shipped.
- **Action**: when v1.4.44 backlog rolls forward, drop W7-B4 + W7-B7
from the "deferred" list. Reflect the actual close in the
retrospective memo.
### PL-L4 — Test-count delta not yet aggregated
- Each phase report cites a local test-count delta against their
worktree baseline (W11: 4815→4861, W12: 4815→4877, W13: 4815→4839,
W14: 4815→4842, W6: 4929 total). With overlapping baselines the
net total against `develop` HEAD is unclear. The phase reports
add up to ~+300 unit tests across W4 + W6 + W11 + W12 + W13 + W14
ignoring overlaps; the full `pnpm test --run` against HEAD will
produce the authoritative number for the CHANGELOG.
- **Action**: run `pnpm test --run` once at HEAD before the version
bump; cite the resulting test count in the CHANGELOG.
---
## Discovery → close coverage matrix
### Audit: analytics-9s
| Item | Severity | Status |
|---|---|---|
| W1 — `computeAvg30LastYearMap` `p-limit(4)` cap | URGENT | **Closed** via `c9a54154` + test pin `3218cb50` |
Verdict: complete. The audit's "recommended fix" body landed
exactly as prescribed. Optional follow-ups (granularity router pin,
24h cache) correctly deferred.
### Audit: mobile-ui
| Severity | Closed | Open / Deferred |
|---|---|---|
| Critical (1) | C1 (raw-count gate + `noDataInRange` split) | — |
| High (5) | H1 Switch 44 px, H2 comparison-baseline 44 px, H3 mood-kebab, H4 sheet close-X, H5 reduced-motion smooth-scroll | — |
| Medium (6) | M1–M6 all closed via W11 | — |
| Low (6) | L1, L2, L4 (folded), L6 | L3 (Textarea primitive — refactor scope), L5 (injection-site SVG — non-trivial refactor) |
Verdict: **all Highs + Criticals closed**. Two Lows correctly
deferred with audit-report-honest documentation in W11 phase report
(L5 specifically rebutting the audit's misread of the element type
— well-handled, Marc-voice).
### Audit: QoL
| Severity | Closed | Open / Deferred |
|---|---|---|
| High (6) | H1 loadError key, H2 Anbieter rename, H3 persistent (partial — see PL-H1), H4 not-found i18n, H5 global-error bilingual, H6 plural forms | — |
| Medium (8) | M3 account-delete, M4 doctor-report disabled rows, M5 offline-banner, M6 errorNetwork, M7 daysAgo "d", M8 full Locale union | M1 (drag-drop reorder — a11y scope), M2 (Coach disable — feature scope) |
| Low (8) | L1, L2, L3, L4, L5, L7, L8 | L6 (onboarding gate) |
Verdict: H3 partial (PL-H1 above) is the single open thread.
Everything else closed cleanly.
### Audit: security
| Severity | Closed | Open / Deferred |
|---|---|---|
| High (2) | H-1 audit-row identifier-hash, H-2 WithingsApiError 1024 slice | — |
| Medium (4) | M-1 check-user audit, M-2 plain-text invariant docblock, M-3 replay-injection scan, M-4 trust-violation bucket | — |
| Low (4) | L-2 snapshot scan guard, L-3 passkey Zod boundary | L-1 (withings_state nonce table — OAuth refactor scope), L-4 (legacy_form_total — wait for v1.4.27 cut) |
Verdict: **all Highs + every Medium + 2/4 Lows closed**. M-3 was
listed as "defer" in the audit but W13 closed it inside the
release. Defence-in-depth surface significantly strengthened.
---
## Release-theme coherence
v1.4.43 has a clear three-strand narrative:
1. **9 s perf regression closure** (W1 + measurable in HAR — the
audit's URGENT call) — single largest user-visible win.
2. **Audit-close marathon** — every Critical + every High across
four audits closed, almost every Medium picked up.
3. **iOS-handoff hardening** — Zod multi-issue rollout (41 routes),
Withings parking + per-kind counters, docker version-cache fix
(B11) closes the v1.4.42 recurring paper-cut.
A clean release-note title: **"v1.4.43 — Analytics 9 s closure,
audit-marathon close (4 reports / 9 waves), Withings parking, iOS
contract Zod rollout."**
This is a *focused polishing release*, not a grab-bag. Compare with
the v1.4.36 / v1.4.37 perf marathons or v1.4.40 architecture
closure — same shape, same coherence.
---
## iOS contract preservation
- **W6 Zod rollout (41 routes)**: envelope strictly additive. Every
caller reading `body.error` still gets a human-readable "Validation
failed" string. New callers branch on `body.details.issues`. iOS
v0.5.4 receives the same shape — no break.
- **W14 `/api/integrations/withings/resume`**: net-new POST endpoint
with no payload. Additive. iOS v0.5.4 doesn't call it; future iOS
release MAY add it.
- **W14 `IntegrationStatus.state = "parked"`**: new enum value in a
string field. Per the 0029 migration comment ("adding new
sentinels in v1.5 doesn't require a migration") this is the
documented evolution pattern. iOS v0.5.4 will fall through to a
generic state branch; verified the new `consecutiveFailuresByKind`
+ `persistentFailureStartedAt` columns are NOT in the API
view-model (`/api/integrations/status` `IntegrationViewModel`
unchanged).
- **W12 `DELETE /api/settings/account`**: pre-existing endpoint; the
v1.4.43 work is UI-only.
- **REG-11 `/api/dashboard/summary`**: shape unchanged; behaviour
fixed — now serves BP/pulse rows older than 7 d. Pure server-side
bug fix, iOS surface improves with no client changes.
**No iOS contract drift.** v1.4.43 is iOS v0.5.4-compatible.
---
## Migration readiness
- Migration `0075_v1443_integration_park` is additive + idempotent +
reversible:
```sql
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "consecutive_failures_by_kind" JSONB;
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "persistent_failure_started_at" TIMESTAMP(3);
```
- Both columns nullable — pre-migration rows keep working. The
status writer back-fills `consecutiveFailuresByKind` lazily on the
next failure write.
- Operator notes draft: see PL-M2 above. The migration is the only
release-blocking ops action.
---
## v1.4.44 backlog seed (recommended)
Carry forward to the next marathon brief:
### High (must do early)
- **WTH-WIRE-WARNING** — finalise the audit-H3 wiring: Prisma
`lastFailureKind` column on `IntegrationStatus`, plumb through
`/api/integrations/status` payload, branch `pillStateFor` to
return `"warning"` when `failureKind === "persistent"` (or drop
the dark `"warning"` pill state per PL-H1 Option A).
- **WTH-DROP-LEGACY-COUNTER** — drop `IntegrationStatus.consecutiveFailures`
per the W14 phase report ("v1.4.44 will drop it once every reader
has migrated"). Migration `0076_v1444_drop_legacy_counter`.
### Medium
- **TEXTAREA-PRIMITIVE** (audit mobile-ui L3) — introduce
`<Textarea>` in `src/components/ui/textarea.tsx`, sweep the 6+
call sites with copy-pasted iOS-zoom defences.
- **INJECTION-SITE-PICKER** (audit mobile-ui L5) — refactor the SVG
`<circle>` hit zones to HTML `<button>` overlays with a Playwright
geometry snapshot harness so the picker doesn't drift during the
refactor.
- **DRAG-DROP-LAYOUT** (audit qol M1) — drag-to-reorder dashboard
widgets with the keyboard arrow buttons as a11y fallback. Defer
iff a11y story takes >1 wave.
- **COACH-DISABLE-TOGGLE** (audit qol M2) — per-user `disableCoach`
flag in Settings → AI; mount-gate `flags.coach && !user.disableCoach`
on FAB + drawer.
- **ONBOARDING-CHAINED-FLOW-GATE** (audit qol L6) — gate the tour
on `onboardingCompletedAt + 24h` so a new user gets the dashboard
between the two onboarding flows.
- **WITHINGS-STATE-NONCE-TABLE** (audit security L-1) — switch
Withings OAuth state to a random nonce + short-lived row in a new
table instead of `${userId}:${nonce}` inline.
- **LEGACY-FORM-TOTAL** (audit security L-4) — move to Postgres if
the v1.4.27 cut still matters, else drop when the legacy form is
removed.
- **B4 + B7 RECONCILE** (PL-L3) — drop these from the v1.4.44
backlog since W14 shipped them. The retrospective memo should
note the v1443-handoff scope vs actual close drift.
### Low
- **Mobile-UI L3 / L5** as above.
- **Cross-tz ±3 h runtime guard** (v1.4.38 carry-forward, still open).
- **Coach replay-injection kill-switch** (audit security M-3 was
closed; v1.4.44 should add the audit annotation observability
per the W13 phase report).
---
## Strengths
S1. **Worktree isolation worked.** All 74 commits attributed to
Marc-André Bombeck. Zero cross-agent drift. The
`feedback_marathon_worktree_isolation.md` rule is now battle-tested
across 3 consecutive marathons.
S2. **Audit → close ratio is exceptional.** 1 Critical / 13 Highs /
24 Mediums closed across four audits + the URGENT analytics fix.
Only 2 Highs and 6 Mediums escaped to the v1.4.44 backlog, all
correctly justified as scope-budget defers.
S3. **W1 perf fix is minimum-delta + clean.** The two-line cap
matches the v1.4.40 W-POOL discipline exactly; the audit's
"Recommended fix" body landed verbatim. The optional follow-ups
(granularity router pin, 24h cache) defer cleanly to v1.4.44.
S4. **The C1 chart-gate fix is product-grade.** Not just "show
chart anyway" — the audit's two-option diagnosis was thought
through into a three-state model (empty range / few-days /
chart-ready) with bilingual copy across all six locales and a new
`charts.needMoreDistinctDays*` register. This is the user-facing
release win.
S5. **W6 Zod rollout is contract-safe.** Every iOS-touching route
retained its status code + audit-action name + meta passthrough.
The `Invalid format:` semantics on the medication CSV importer
survive the rewrite via `meta.errorCode`. 220 new test cases pin
the multi-issue contract.
S6. **W13's M-3 (replay-injection) closure exceeds audit
recommendation.** The audit listed M-3 as "defer — pin the pattern
in v1.4.44". W13 closed it with a refusal short-circuit, an audit
annotation, AND an integration test. Closure-above-scope is the
right shape when the fix is bounded.
S7. **W14's per-kind counter design is migration-safe.** The
legacy `consecutiveFailures` integer stays in place for one release
with `Math.max(consecutiveFailures, buckets.*)` reader semantics; a
v1.4.44 migration drops it after every reader has converged. This
is the soft-migration pattern that's worked across v1.4.40
soft-delete and v1.4.42 queryKey-factory closures.
S8. **B11 docker version-cache fix closes a recurring paper cut.**
The v1.4.42 release shipped a stale `package.json` bundle (1.4.41
in the 1.4.42 image) that required a workflow_dispatch retrigger.
The new `ARG NEXT_PUBLIC_APP_VERSION` baked into the image layer
cache key makes this impossible by construction. The runtime
`/api/version` reads from env-first so the bundle version is
authoritative.
S9. **PWA SW cache-version self-heal is closed.** L3 in the QoL
audit flagged `CACHE_VERSION = "v1.4.38.4"` as 4 releases stale.
The `scripts/generate-sw-version.mjs` prebuild step now writes
`sw-version.js` per build and the SW loads it via `importScripts`.
v1.4.38.4 self-heal architecture is now genuinely self-healing
across every release.
S10. **REG-11 was caught by Marc, not by the audit waves.** The
dashboard `latestEver` SQL bug existed since v1.4.38 W-F and only
manifested on iOS where the BP/pulse tile was blank for accounts
whose last reading was older than 7 days. 5 iOS-side attempts had
failed before Marc traced it to server SQL. Worth noting that the
mobile-ui audit didn't catch it (the dashboard summary endpoint
isn't a UI surface; it's a query shape). Future audits could
benefit from a "endpoint-vs-UI-state contract" reviewer pass.
---
## What to do next (in order)
1. Address PL-H1 — recommend Option A (drop dark `"warning"` pill code).
2. Run `pnpm test --run` at HEAD; record the test count.
3. Bump `package.json` version to `1.4.43`.
4. Draft CHANGELOG `[1.4.43]` entry per the v1.4.42 shape (Added /
Changed / Fixed / Performance / Operator notes; cite all 4 audit
reports + 7 phase reports + REG-11). Stay PII-clean.
5. Operator notes must say "**Run `pnpm exec prisma migrate
deploy` before container restart**" per PL-M2.
6. Squash-merge develop → main + tag v1.4.43 + GH release + Coolify
deploy + verify `/api/version → 1.4.43` per the handoff steps 8–12.
7. Carry the v1.4.44 backlog forward per the seed above.
This is a clean release. Ship it.
@@ -0,0 +1,203 @@
# v1.4.43 QA-Security findings (W10-QA-SECURITY)
Scope: read-only review of `git diff 2c68a48d..develop` covering 74
commits between v1.4.42 and the v1.4.43 release candidate.
Reviewer: W10-QA-SECURITY (static review, no execution).
## Verdict
**APPROVE** — ship as-is.
Every Critical / High / Medium / Low from the W-AUDIT-SECURITY round is
closed in code with regression tests, or explicitly documented as a
defence-in-depth invariant. No CORS / CSP regression; no migration
constraint relaxation; no PII leak in any new audit row; the
trust-violation tighter bucket lands across every IP-keyed auth surface;
the W6 Zod-rollout preserves every status code (sole 400 case is the
consent route, intentional and commented). The two new endpoints
(`/api/integrations/withings/resume` DELETE and
`/api/settings/account` DELETE) are user-scoped, rate-limited, and
session-invalidating where required.
## Critical (must fix before tag — vuln / data leak / auth bypass)
**None.**
## High (should fix before tag — defence-in-depth gap)
**None.** All H findings from the audit round closed:
- **H-1** (`auth.login.failed` raw identifier) — **CLOSED** in
`b70cdcae`. `src/app/api/auth/login/route.ts:71-74` now writes only
`{ reason: "user_not_found_or_no_password" }` — identifier field
dropped entirely. Regression test in
`src/app/api/auth/login/__tests__/route.test.ts:108-130` pins both
the absence of an `identifier` key AND the literal email string in
the serialised audit row.
- **H-2** (`WithingsApiError.message` un-capped) — **CLOSED** in
`921d31b8`. `src/lib/withings/response-classifier.ts:252-255` caps
the message at 1024 chars in the constructor, so every downstream
audit / notification / pg-boss retry path inherits the bound.
Regression test in
`src/lib/withings/__tests__/response-classifier.test.ts:180-191`
pins the cap for a 4000-char upstream while preserving the legacy
`Withings <verb> error: <status>` prefix.
## Medium
**None blocking.** All M findings closed or pinned-as-invariant:
- **M-1** (`check-user` un-audited) — **CLOSED** in `7f84e950`.
`src/app/api/auth/check-user/route.ts:109-137` now hashes the
identifier via `hashToken` and persists every branch (`not_found`,
`passkey_only`, `email_fallback`, `exists`) to `AuditLog`. Wide-event
`annotate()` retained; audit row is durable + hashed.
- **M-2** (Telegram parseMode invariant) — **DOCUMENTED** in
`8767f819`. `src/lib/integrations/status.ts:828-851` carries a
SECURITY INVARIANT docblock explaining why the admin-alert path must
not flip to `parseMode: "HTML"`. No code change; the existing plain-
text rendering keeps the upstream-influenced string inert.
- **M-3** (Coach replay-injection on prior turns) — **CLOSED** in
`81b1eeea`. `src/app/api/insights/chat/route.ts:198-233` re-runs
`detectRefusal` against every user-role turn re-loaded from
`coach_messages.encryptedContent` before building the transcript.
On a hit, the SSE short-circuits with a refusal AND a durable
`audit.coach.replay-injection` row carrying conversation id, turn
index, and matched reason — never the message content. Integration
test in `tests/integration/coach-chat.test.ts` (committed alongside)
pins both the short-circuit and the audit-row shape.
- **M-4** (trust-chain misconfig → shared `"unknown"` bucket) —
**CLOSED** in `d324d516`. `src/lib/rate-limit.ts:84-127` adds
`checkAuthSurfaceRateLimit`, which consults
`getClientIpOrTrustWarning` and, on `trustViolation === true`,
routes the request to a shared global bucket
`auth:anon:trust-violation` capped at 100/15min across every auth
surface. Six routes converted (login, register, passkey login-
options, passkey login-verify, refresh, check-user); `password` is
per-user and stays on direct `checkRateLimit`. Test suite in
`src/lib/__tests__/rate-limit-auth-surface.test.ts` covers all four
pivot conditions (clean chain + IP, clean chain + null IP, violation
+ IP, violation + null IP) plus the cross-surface bucket-sharing
property.
## Low
All L findings closed or documented:
- **L-2** (Coach `userPrompt` raw string concat) — pinned via
`58f48413` regression-test guard
(`src/lib/ai/coach/__tests__/snapshot.test.ts`).
- **L-3** (`passkey/login-verify` raw cast) — **CLOSED** in `ef7e1f03`.
`src/lib/auth/passkey.ts:13-37` adds the
`authenticationResponseSchema` Zod narrow + applies it inside
`verifyAuthentication`; the SimpleWebAuthn cryptographic verifier
retains downstream ownership. `.loose()` keeps forward-compat with
WebAuthn extension fields.
- **L-1, L-4** — deferred to v1.4.44 per the audit round; verified no
regression in this release.
## Strengths
1. **W3/W13 audit-row PII discipline is end-to-end.** Every new
`auditLog` call in the diff writes either a `userId` (server-known),
a hashed identifier (`hashToken`), a reason enum, or sanitised Zod
issues (`sanitiseZodIssues` — path/code/message only, `issue.params`
stripped). Searched the diff for `auditLog` + raw `identifier` /
`email` / `password` — no hits in any new call site.
2. **W6 Zod-rollout preserves status codes byte-for-byte.** Spot-checked
every new `returnAllZodIssues(parsed.error, ...)` call site against
the prior `apiError(parsed.error.issues[0].message, ...)`: 36 sites
stayed on 422; the 3 consent routes stayed on 400. Confirmed against
`2c68a48d:src/app/api/consent/ai/route.ts` (the only 400 originator);
`returnAllZodIssues(parsed.error, 400)` carries an inline comment
pinning the intentional preservation. No accidental 422→400 swap or
the reverse.
3. **`sanitiseZodIssues` privacy floor is tight.** `path`, `code`,
`message` only — `issue.params` (which Zod uses to echo the rejected
value for some issue codes) stays server-side
(`src/lib/api-response.ts:37-45`). Verified across the full rollout.
4. **W14 resume endpoint is correctly scoped + rate-limited.**
`/api/integrations/withings/resume` requires `requireAuth()`, then
keys `checkRateLimit` by `withings-resume:{user.id}` (5/min). The
downstream `resumeIntegrationFromPark(user.id, "withings")` queries
and upserts on the `userId_integration` composite-unique — cross-
user clobber impossible. Idempotent: a second call sees
`existing?.state !== "parked"`, returns `wasParked: false` and
writes no audit row. No CORS / CSP impact (no new origin or header).
5. **W12 account-delete cascade is bounded + GDPR-clean.**
`/api/settings/account` DELETE requires `requireAuth()` + a literal
`confirm: "DELETE_ACCOUNT"` string in the body (typed-as-string then
strict-equality compared, not just truthy). Last-admin guard
prevents lock-out. Sessions, API tokens, refresh tokens all revoked
via `destroyAllSessions(userId)` in a single Prisma transaction —
every revoke is `where: { userId }`. Feedback + AuditLog rows are
purged for GDPR Art. 17 completeness BEFORE the final
`user.delete()` (otherwise the cascade `SetNull`s them, leaving PII
orphans). Audit row written BEFORE the deletion sweep; the row's
own `userId` is purged in the subsequent `auditLog.deleteMany`.
6. **Migration 0075 (`integration_park`) is purely additive.** Two
nullable columns (`consecutive_failures_by_kind jsonb`,
`persistent_failure_started_at timestamp`) with `ADD COLUMN IF NOT
EXISTS` guards. No NOT NULL, no FK change, no unique index touched.
`state` column stays free-form string; `"parked"` is recognised at
the application layer. `onDelete: Cascade` on the FK preserved.
7. **CSP / proxy unchanged.** `git diff 2c68a48d..develop -- src/proxy.ts`
is empty. The HSTS, CSP-with-route-gated `connect-src`,
`frame-ancestors`, `Referrer-Policy`, `Permissions-Policy` headers
continue to ship from the same builder. No new endpoint declared
its own headers / CORS shim. `grep "Access-Control-Allow" src/`
returns clean.
8. **W3-SECURITY regression test is the right shape.**
`login/__tests__/route.test.ts:108-130` asserts both the absence
of the `identifier` key in `payload.details` and the literal
typed-email string anywhere in the JSON serialisation. A future
refactor that re-introduces the field under any new name still
fails the second assertion. Same pattern lands the W13 M-1
check-user audit-row test.
9. **W13 M-3 (Coach replay-injection) audit-row content is leak-free.**
The new `audit.coach.replay-injection` row carries
`conversationId`, `turnIndex`, and the matched `reason` enum —
never the raw turn content. So an injection payload that surfaces
the audit row doesn't survive into the audit ledger itself.
10. **Dashboard widgets audit-row dedup (B2) is process-local + safe-
failure.** The in-process memo on `(userId, action)` keyed at 60 s
drops at most one row per process per minute on a tight retry loop
— far below the operator-grep threshold but eliminates the
audit-flood DoS surface. Memo resets on process restart (one extra
row per restart; tolerable). Cluster deploys keep one row per pod
per minute, which is fine.
## Out-of-scope items observed (no action required)
- The Coach SSE `detectRefusal` regex bank is still the only injection
barrier — same as v1.4.42. The W13 M-3 close adds replay-injection
guard but does not strengthen the regex itself (deferred per the
audit-round verdict).
- `getClientIpOrTrustWarning` still fires the `console.warn` only once
per process. Operators relying on the warn for chain misconfig
detection still need external alerting. The tight global bucket
(W13 M-4) caps the blast radius regardless.
- The `withings_state` cookie still carries `userId:nonce` plaintext
(L-1) — deferred to v1.4.44.
---
(verdict + critical/high count + filename)
@@ -0,0 +1,368 @@
# v1.4.43 QA Senior-Dev findings (W10-QA-SENIOR-DEV)
Scope: architectural + correctness review of `git diff 2c68a48d..develop`
covering 74 commits between v1.4.42 and the v1.4.43 release candidate.
Reviewer: W10-QA-SENIOR-DEV (read-only static review, no execution).
## Verdict
**APPROVE WITH 1 MEDIUM + 4 LOW** — ship as-is; address the bucket
back-fill off-by-one before v1.4.44 drops the legacy `consecutive_failures`
column.
No Critical / High findings. The five sentinel landings (W1-ANALYTICS,
W14-WITHINGS-PARK, W6-ZOD-ROLLOUT, W13-RATE-LIMIT-WRAPPER, W12-ACCOUNT-DELETE)
are architecturally sound. Cherry-pick aftermath commits (`32ca196e`,
`154e6fa7`) cleanly close branch-merge collateral, not deeper bugs.
Pre-existing `workout-batch-{create,race}` integration failures
confirmed pre-date this release (last touched in v1.4.25).
---
## Critical
None.
## High
None.
## Medium
### M-1 — Per-kind bucket back-fill is permanently 1 behind the legacy counter on the transition row
- **File**: `src/lib/integrations/status.ts:375-395` (back-fill branch
inside `recordSyncFailure`).
- **Trace**: a v1.4.42 row at `consecutive_failures=3, ...byKind=null,
state=error_reauth` receives a fresh `reauth_required` failure. The
code path:
1. `startingBuckets = null` (column predates migration).
2. `buckets = backfillBuckets(3, "reauth_required")` →
`{transient: 0, reauth_required: 3, persistent: 0}` — seeds the
bucket with the **legacy count**, NOT `legacy + 1`.
3. The increment branch at line 384 (`if (startingBuckets)`) does
NOT fire because `startingBuckets` is still null in this scope.
4. The fall-through `else if (!existing)` at line 387 does NOT fire
because `existing` is truthy.
5. Upsert writes `consecutive_failures: { increment: 1 }` → DB row
becomes `consecutive_failures=4, consecutiveFailuresByKind.reauth_required=3`.
After this write: legacy counter is 4, bucket is 3 — off by 1. On
every subsequent failure the `startingBuckets` path increments by 1,
so the gap persists forever. The docstring comment at line 391-395
claims "back-fills to reauth_required: 4 and this 5th failure makes
the legacy +1 path bring the bucket to 5" — but there is no separate
`+1` path that runs on the back-fill branch. Either the comment or
the code is wrong.
- **Operational impact today**: tolerable. The alert ladder at line 487
reads `Math.max(row.consecutiveFailures, ...buckets)` so the legacy
column still drives paging decisions; the audit `bucketCount` field
reports the smaller bucket value while `attemptNumber` reports the
correct higher legacy count, creating a 1-row diagnostic confusion
but no functional miss. Operators who grep audit rows by
`bucketCount >= 3` will under-count by exactly one streak.
- **Impact on v1.4.44**: the migration comment at the top of
`0075_v1443_integration_park/migration.sql` says "the single-column
`consecutive_failures` integer stays in place for one release as a
fallback (callers reading the alert-ladder still use
`Math.max(...buckets)`); it will be removed in v1.4.44 once every
reader is migrated." When that drop lands, the alert ladder will lose
the `Math.max(row.consecutiveFailures, …)` floor and start paging at
bucket≥3 instead of legacy≥3 — every back-filled row will page one
failure too late.
- **Recommended fix (one-line)**: in `backfillBuckets` change
`buckets[currentKind] = Math.max(0, legacyCount)` →
`buckets[currentKind] = Math.max(0, legacyCount) + 1`, and update the
unit test at `src/lib/integrations/__tests__/status.test.ts:666` to
expect `reauth_required: 4` instead of `3`. The "this failure IS
counted in that seed" docstring then becomes accurate.
- **Severity rationale**: M, not High — the legacy column carries the
load until v1.4.44; the fix is one line + one test expectation flip;
no live production user is currently affected because the legacy
counter still drives the alert ladder. Worth fixing before the
v1.4.44 drop ships.
## Low
### L-1 — `verifyRegistration` still uses raw `as RegistrationResponseJSON` cast
- **File**: `src/lib/auth/passkey.ts:153`.
- **Trace**: W13 L-3 closed the cast on `verifyAuthentication` (line
225-229 — explicit Zod parse via `authenticationResponseSchema`) but
the symmetric `verifyRegistration` path at line 137-160 still does
`response: response as RegistrationResponseJSON` without any prior
shape validation. SimpleWebAuthn's `verifyRegistrationResponse`
throws on schema mismatch, so the security posture is identical to
the pre-W13 authentication path the wave deliberately closed. The
W13 report does not document this as out-of-scope; it appears to be
an oversight or an explicit scope-cap.
- **Recommended fix**: mirror the `authenticationResponseSchema` with
`registrationResponseSchema` and apply the same `safeParse → throw on
failure` pattern. Two-test addition: valid registration accepted,
malformed body rejected before reaching the verifier.
- **Severity rationale**: L because the SimpleWebAuthn verifier does
own the cryptographic validation downstream and the registration
surface is gated by an authenticated requireAuth (lower attack
surface than the un-authenticated login flow). Documenting it here
so a v1.4.44 sweep closes the asymmetry.
### L-2 — Trust-violation tight bucket is global across the cluster, not isolated per process
- **File**: `src/lib/rate-limit.ts:82-127`.
- **Trace**: `TIGHT_ANON_KEY` is a single string `"auth:anon:trust-violation"`
keyed into the Postgres `rate_limits` table, so a misconfigured
deployment serving 20 anonymous callers across 5 pods will see them
all share the same 100/15min cap (across the cluster, not per pod).
The brief asked to verify "tight-global-bucket isolation per
process" — confirmed it is NOT per-process; it is per-cluster, since
the underlying `checkRateLimit` is Postgres-backed. The behaviour
matches the existing `auth:login:<ip>` semantics (also cluster-wide
via Postgres) so this is design-consistent rather than a bug.
- **Impact**: in a real trust-violation incident a single attacker
could exhaust the 100/15min cap and lock every other anonymous
caller out of every auth surface across the cluster until the
operator fixes `TRUST_PROXY_HOPS`. The `console.warn` is once-per-
process — so an N-pod cluster emits N warnings before the operator
notices. The brief asked about "false positives when TRUST_PROXY_HOPS
is correctly configured" — confirmed clean: a correct config never
trips `trustViolation === true` and the per-IP bucket fires unchanged.
- **Recommended action**: none in v1.4.43. Document the cluster-wide
scope in the docstring of `checkAuthSurfaceRateLimit` so the next
operator doesn't expect per-pod isolation.
### L-3 — `recordSyncFailure` read-then-write is not transactional; concurrent failures can clobber bucket increments
- **File**: `src/lib/integrations/status.ts:347-455`.
- **Trace**: the function reads the existing row at line 347, computes
the new bucket envelope in JS, then upserts with the full computed
envelope. The integer `consecutiveFailures` uses atomic
`{ increment: 1 }` (line 451), but `consecutiveFailuresByKind: buckets`
(line 452) is a full overwrite from a stale read. If two concurrent
failure callers race the read window, the second upsert clobbers
the first's bucket increment. Net effect: bucket count is the
pre-read value + 1, not the actual concurrent count.
- **Impact today**: minimal. The only callers are Withings + moodLog
per-user sync routines, each of which holds a per-(user, integration)
lock implicitly via pg-boss job singletons — concurrent failures for
the same user on the same integration are not actually possible in
the deployed topology. The legacy `{ increment: 1 }` column still
drives the alert ladder so an under-count in the bucket doesn't
affect paging.
- **Recommended action**: none in v1.4.43. Before v1.4.44 drops the
legacy column, either wrap the read+upsert in `prisma.$transaction`
with a row-level lock, or replace the bucket overwrite with a
Postgres jsonb update that atomically increments only the matching
bucket (`jsonb_set` + `(value::int + 1)::text::jsonb`).
### L-4 — Audit-dedup memo is per-process only on a multi-pod deployment
- **File**: `src/app/api/dashboard/widgets/route.ts:54-72`.
- **Trace**: the `auditDedupMemo` Map at module scope is in-process
only — a cluster with 5 pods can write 5 identical audit rows per
minute even under the dedup. The docstring acknowledges this: "Cluster
deployments still get one row per process per minute, which is fine
for the operator-grep use case the audit row exists for." Verified
the docstring is accurate and the choice is intentional. The 512-entry
opportunistic GC at line 66-69 is correct (drops entries older than
the window) and cannot grow unbounded for any realistic load.
- **Note**: only `dashboard/widgets/route.ts` got the dedup; every other
W6-rollout route writes one audit row per Zod miss with no dedup.
Verified by `grep shouldEmitAuditRow` returning a single file. This
is consistent with the W6 report's framing of widgets as the
iOS-contract-loop-prone surface; other surfaces don't see the
loop pattern often enough to justify the memo overhead.
---
## Strengths
1. **Schema migration soundness (W14 / `0075_v1443_integration_park`)**.
- `ADD COLUMN IF NOT EXISTS` on both new columns — idempotent against
reruns.
- Both columns nullable with no DEFAULT, so existing rows stay
compatible without a backfill UPDATE (the application-layer
`backfillBuckets` handles per-row migration on next write — though
see M-1).
- The migration SQL header documents the reverse migration explicitly:
`ALTER TABLE ... DROP COLUMN IF EXISTS` on both columns + an
`UPDATE` clearing any `state = 'parked'` rows back to
`error_transient`. Reversible.
- `state` stays a free-form string column per the 0029 invariant —
adding `"parked"` is a no-op at the SQL layer; the enum widening is
application-owned. Consistent with the codebase's documented
pattern.
- Schema Prisma type updates added BOTH columns + the `"parked"` enum
comment update in one commit (`bd538b02`). No drift between
migration SQL and Prisma schema.
2. **`p-limit(4)` cap on `computeAvg30LastYearMap`** is correctly
isolated.
- The pin test at
`src/lib/analytics/__tests__/summaries-slice.wmy-cap.test.ts` walks
a 15-type fan-out with controllable resolvers and asserts in-flight
count never exceeds 4 across the full drain cycle. Pinned at the
module-level constant (`WMY_FANOUT_CONCURRENCY = 4`).
- The cap composes additively with the thick-route's
`ANALYTICS_TYPE_FETCH_CONCURRENCY = 4`: on a dashboard mount that
fires both `?slice=summaries` and the default slice in parallel,
the worst-case combined pool draw is `4 + 4 = 8` slots from
analytics, well under the `pg.Pool max=20` v1.4.40 raised it to.
No collision risk.
- Verified zero behavioural delta in the rollup-tier read paths;
this is purely a fan-out cap that defers work, not a result
change.
3. **W6 Zod-rollout pattern**.
- All 35 routes preserve the existing `{ data: null, error: <string>, ...}`
envelope. Added `details.issues` is strictly additive — iOS v0.5.4
clients reading `body.error` see the unchanged `"Validation failed"`
string. Pinned by `src/lib/__tests__/api-response-zod.test.ts` +
27 new route-level test files.
- `sanitiseZodIssues` projection drops `issue.params` consistently —
verified across all 35 callers; every route-level test asserts
`Object.keys(issue).sort() === ["code", "message", "path"]`.
- `safeJson` is called BEFORE the Zod parse on every POST/PUT/PATCH
route — verified spot-check on `measurements/route.ts` (line 568),
`dashboard/widgets/route.ts`, `medications/route.ts`. The
content-type and JSON-parse failures still return their dedicated
415/400 envelopes before any Zod work runs.
- Audit-row writes use the fire-and-forget `.catch(() => {})` pattern
consistently across every W6 site. Verified the audit-write-
rejection survival case is pinned by tests on multiple routes.
- The dedup memo (B2) is correctly scoped to widgets only — every
other route writes one audit row per miss because their failure
patterns don't loop.
4. **W13 `checkAuthSurfaceRateLimit` wrapper introduction**.
- Signature backward-compat: the wrapper returns `RateLimitResult &
{ ip: string | null }`. Existing call sites picked the IP up from
`getClientIp(request)` before; they now read `rl.ip ?? "unknown"`.
The shape extension is additive — `result.allowed`,
`result.remaining`, `result.resetAt` are byte-identical for the
happy path.
- Trust-violation tight bucket fires only when
`getClientIpOrTrustWarning` reports `trustViolation === true` —
verified the helper at `src/lib/api-response.ts:268-300`. A clean
`TRUST_PROXY_HOPS=1` + single-XFF deployment never trips the tight
bucket; pinned by
`src/lib/__tests__/rate-limit-auth-surface.test.ts:75-87`.
- Test coverage is comprehensive: 8 tests on the wrapper, three
existing test files updated to mock both `checkRateLimit` and
`checkAuthSurfaceRateLimit`. The `154e6fa7` cherry-pick aftermath
commit closes the last test-mock gap (login + register) — verified
this is a green-up commit, not a paper-over.
5. **W12 `/api/settings/account` DELETE cascade**.
- User-scoped by `user.id` from `requireAuth()` — no admin-acting-as-user
surface, no email/username lookup. Bounded delete.
- `destroyAllSessions(userId)` runs BEFORE `prisma.user.delete` (line
54) so concurrent sibling sessions can no longer authenticate by
the time the cascade completes. The integration test at
`tests/integration/settings-account-delete.test.ts:154-177` pins
this against real Postgres.
- Audit row is written BEFORE the delete at line 47-51 — the
comment on line 44-46 acknowledges this and explains the
intentional follow-up purge at line 62 for GDPR Art. 17 erasure
completeness. The `annotate({ action: "settings.account.delete" })`
wide-event at line 67-70 IS the durable operator-facing trail; the
audit row gets created+purged inside the same logical operation by
design. The trail is correct and explicit.
- Last-admin guard at line 34-39 — single-admin instances can't
foot-shoot themselves into an unmanaged install. Pinned by test
at line 208-219.
- 422 on missing/wrong confirmation, 401 on no session, 400 on
last-admin guard — all preserved and pinned.
6. **W14 `parked` state machine**.
- The state machine is sticky-correct: once parked, the row stays
parked until `resumeIntegrationFromPark` clears it OR a success
arrives (which can't happen because `isReauthRequired` returns
`true` for both `error_reauth` and `parked`, short-circuiting the
sync entry-point).
- The 24h park threshold uses `persistentFailureStartedAt` (wall-
clock anchor on the first persistent failure of a streak) +
`PARK_PERSISTENT_FAILURE_AFTER_MS=24h`. The streak anchor is
stamped on the first persistent failure and cleared on success —
same idempotency semantics as the existing `alertedAt` field.
- `persistentStreakBefore` snapshot at line 369 correctly reads the
pre-increment bucket value so the "first persistent failure of a
fresh streak" detection at line 405 works on a fresh streak +
correctly NO-OPs on a continuing streak. Subtle but correct.
- Park-event audit row at line 524-537 is written ONCE per transition
(not per failure) by gating on `existing?.persistentFailureStartedAt`
— the FIRST persistent failure that crosses 24h sees the anchor
populated from the prior write, so the audit row fires exactly
once. Verified by unit test "flips state to parked and writes an
audit row once persistent streak > 24h".
7. **Cherry-pick aftermath quality**.
- `32ca196e` (chart-i18n restore) — verified all 4 keys (W11 +
W2 additions) restored across all 6 locales. The merge collision
was W12's i18n tighten on the same `messages/*.json` root sweep
wiping out sibling-wave additions; the restore is verbatim, not
a behaviour change. Reversibility verified: green CI on the keyed
locales, no orphan key warnings from `pnpm i18n:check`.
- `154e6fa7` (auth-test mocks) — adds `checkAuthSurfaceRateLimit`
mock to login + register test factories that already mocked the
underlying `checkRateLimit`. The W13 wave wired the surface but
missed the legacy test files; this commit closes a green-CI gap,
it does not paper over a runtime regression. Verified the test
factories already pre-existed; only the new mock function entry
was added.
8. **Pre-existing failure attribution**.
- `tests/integration/workout-batch-{create,race}.test.ts` last
touched in v1.4.25 (`caf2371c` + `6ed925fe` + `17b8d8d2 chore(release): v1.4.25`).
`git log 2c68a48d..HEAD -- tests/integration/workout-batch-*` returns
EMPTY — no v1.4.43 commit touches either file. The W13 phase
report's flag is correct: pre-existing, out-of-scope for v1.4.43.
9. **iOS v0.5.4 backward compatibility**.
- 35 W6 routes preserve `body.error` as the human-readable string
(verified via test: `expect(body.error).toBe("Validation failed")`).
- The new `details.issues` field is additive — JSON decoders that
don't model it skip it.
- Status codes preserved across every conversion: every `apiError(...,
422)` became `returnAllZodIssues(parsed.error, 422)` and every
`apiError(..., 400)` became `returnAllZodIssues(parsed.error, 400)`.
- DELETE `/api/settings/account` is a new endpoint, NOT a contract
change to existing iOS surface — iOS v0.5.4 doesn't call it.
- The new `parked` state is exposed via the existing integration-
status response shape (the IntegrationStatusPill component reads
`state` as a string); iOS doesn't currently surface this UI so
the new sentinel is a no-op for v0.5.4 clients.
- `auth.check-user` audit row is server-side only; the response
shape (`{ branch, hasPasskey, hasPassword }`) is unchanged.
10. **Docker tag-baking (B11) closes the v1.4.42 stale-bundle paper-cut**.
- `NEXT_PUBLIC_APP_VERSION` is passed via `--build-arg` from the
docker-publish workflow, picked up as both `ARG` + `ENV` in the
builder + runner stages of the Dockerfile, then read by
`/api/version` at runtime with package.json fallback. The
build-arg becomes part of the BuildKit layer cache key, so a
release-tag bump invalidates every downstream layer that
depends on it. The recurring "stale package.json version baked
into the bundle" failure mode from the v1.4.42 retrigger is now
structurally closed.
---
## Items verified clean — listed for the trail
- `pull_policy: always` (v1.4.34.2 fix) still in `docker-compose.yml`.
- `pg-boss` worker registry includes the `withings-resume` queue is N/A —
the route is synchronous (no pg-boss work; just calls
`resumeIntegrationFromPark` directly).
- No new `as X` type-narrowing casts past Zod boundaries except the
documented `parsed.data as unknown as AuthenticationResponseJSON`
double-cast (intentional: SimpleWebAuthn types don't admit Zod's
`passthrough`-loose-shape).
- All 35 W6 routes still set `Content-Type: application/json` via the
shared `apiError` / `returnAllZodIssues` helpers (NextResponse.json
default).
- The `withings/resume` endpoint enforces `requireAuth()` → user-scoped
by `user.id` → no cross-user resume possible.
## Sign-off
Verdict: **APPROVE WITH 1 MEDIUM + 4 LOW**. Recommend addressing M-1 in
v1.4.44 before the legacy `consecutive_failures` column drops. The four
Lows are deferable — none affect v1.4.43 production behaviour.
Critical / High count: **0**.
@@ -0,0 +1,218 @@
# Round v1.4.43 — W10-QA-SIMPLIFIER findings
**Reviewer:** W10-QA-SIMPLIFIER (read-only)
**Scope:** `git diff 2c68a48d..develop` (74 commits, 197 files, +12,301 / −460)
**Date:** 2026-05-21
## Verdict
CHANGES REQUESTED — one Critical regression (relative-time i18n keys reverted across all six locales) and one High dead-code branch (IntegrationStatusPill `warning` state never emitted) must land before tag. Five Medium and four Low items are cleanup polish.
## Counts
| Severity | Count |
|----------|-------|
| Critical | 1 |
| High | 2 |
| Medium | 5 |
| Low | 4 |
---
## Critical — must fix before tag
### C-1. Relative-time i18n keys silently swept out by W12 tighten — every Insights surface now renders raw key strings
**Symptom:** `formatRelativeTime()` reads pluralised keys (`insights.relativeMinutesAgoOne` / `…Other`, `…HoursAgoOne` / `…Other`, `…DaysAgoOne` / `…Other`) — none of those keys exist in any of `messages/{de,en,es,fr,it,pl}.json`. The translator returns the raw key on a miss (see `src/lib/i18n/context.tsx:104-107`), so every relative-time render now displays the literal string `insights.relativeMinutesAgoOther` (or its variants) to users.
**Root cause:** commit `b8ca1c74` (W4 — `i18n(relative-time): branch on count === 1 to render singular forms`) correctly added the pluralised keys to all six locales and switched `formatRelativeTime` to read them. Commit `2390438e` (W4 — `i18n: tighten 404 + global-error copy across all locales`) then reverted the locale-file half of that change — the relative-* diff in `2390438e` shows `-relativeMinutesAgoOne / -relativeMinutesAgoOther / -relativeHoursAgoOne / -relativeHoursAgoOther / -relativeDaysAgoOne / -relativeDaysAgoOther` deleted and the legacy `relativeMinutesAgo / relativeHoursAgo / relativeDaysAgo` keys re-added. The code in `relative-time.ts` was not reverted, so it now reads non-existent keys.
**Confirmed missing:**
```
grep -c "relativeMinutesAgoOne\|relativeMinutesAgoOther\|…" messages/*.json
de.json:0 en.json:0 es.json:0 fr.json:0 it.json:0 pl.json:0
```
**Visible call sites (all three are user-facing):**
- `src/components/insights/coach-panel/history-rail.tsx:146` — Coach conversation history rail
- `src/components/insights/daily-briefing.tsx:323` — Daily Briefing "generated X ago" caption
- `src/components/insights/hero-strip.tsx:135` — Insights hero "Generated {time}" caption
**Files involved:**
- `src/lib/i18n/relative-time.ts:26-31, 35-40, 43-48` — code reads the missing keys
- `messages/{de,en,es,fr,it,pl}.json:1153-1156` — legacy keys present, pluralised keys absent
**Suggested fix:** restore the six pluralised keys per locale and keep the legacy keys as a transient compatibility shim, OR revert `relative-time.ts` to the legacy (non-pluralised) key reads + accept the "vor 1 Minuten" grammar drift the W4 plural commit was trying to fix. Decision is Marc's; either way the deploy cannot ship with `formatRelativeTime` reading keys that the i18n bundle does not contain. Add a guard test that asserts `t("insights.relativeMinutesAgoOne") !== "insights.relativeMinutesAgoOne"` so a future locale-file sweep cannot regress this silently.
---
## High — fix before tag if possible
### H-1. `IntegrationStatusPill` `warning` state defined and rendered but never selected — dead branch
**Symptom:** `IntegrationStatusPill` was widened in v1.4.43 W14 from three pill states to five (`connected`, `warning`, `error`, `parked`, `disconnected`). The `warning` branch carries its own copy (`warningServerError` i18n key, all six locales), its own chip class (`warningChipClass`), and its own switch case (`integration-status-pill.tsx:125-128`). No production code path ever passes `state="warning"`.
**Files involved:**
- `src/components/settings/integration-status-pill.tsx:47-52` — `IntegrationPillState` union includes `"warning"`
- `src/components/settings/integration-status-pill.tsx:108, 125-128, 157, 167` — render branches for `"warning"`
- `src/components/settings/integrations-section.tsx:127-142` — `pillStateFor()` switches over `IntegrationState` and maps to `connected | error | parked | disconnected`; never returns `"warning"`
**Evidence (grep across the tree):**
```
grep "state=\"warning\"\|state: \"warning\"\|return \"warning\"" src/
# only hits inside integration-status-pill.tsx (the dead branch itself)
```
**Suggested fix:** either (a) decide what `warning` is supposed to surface (one transient failure? one persistent without the 24h park?) and wire `pillStateFor()` to return it, OR (b) drop the `warning` branch from the union + remove the chip class + delete the `warningServerError` keys across all six locales. As-is the branch ships dead — knip won't catch this because the type is referenced through the union — and the locale-file `warningServerError` key is dead in every bundle.
### H-2. `chart-overlay-prefs` 422 audit row has no dedup — sibling of `widgets` (B2) but the dedup was not applied here
**Symptom:** v1.4.43 B2 added an in-process 60 s `(userId, action)` dedup memo to `src/app/api/dashboard/widgets/route.ts` so a misbehaving client looping on a 422 cannot flood the audit ledger. The sibling route `src/app/api/dashboard/chart-overlay-prefs/route.ts` was migrated to `returnAllZodIssues` in the same release (W6) and now writes the same kind of breadcrumb on every validation failure — without dedup. A retry loop against `/api/dashboard/chart-overlay-prefs` pumps every 422 into `AuditLog`.
**Files involved:**
- `src/app/api/dashboard/chart-overlay-prefs/route.ts:57-77` — audit row write, no dedup gate
- `src/app/api/dashboard/widgets/route.ts:45-79` — dedup gate that should be applied here too
**Suggested fix:** lift the `auditDedupMemo` + `shouldEmitAuditRow` helpers into a shared module (e.g. `src/lib/audit-dedup.ts`) keyed by `(userId, action)` and have both routes call it. Drop the `__resetAuditDedupMemoForTests` from the widgets route, export the reset helper from the shared module instead.
---
## Medium
### M-1. `formatDateOrRelative` and `formatRelativeTime` are 90 % duplicate logic with subtly divergent i18n keys
**Symptom:** v1.4.43 added `formatDateOrRelative` to `src/lib/format.ts:73-122` while leaving `formatRelativeTime` in `src/lib/i18n/relative-time.ts:16-49` intact. Both compute relative phrases for "just now / N min ago / N h ago", branch on the same time buckets, and live behind a `t()` translator. The only differences:
- `formatRelativeTime` also handles the ≥ 24 h "N days ago" bucket
- `formatDateOrRelative` falls back to `formatDateTime(iso)` after 24 h instead of "N days ago"
- `formatRelativeTime` reads the pluralised `…One` / `…Other` keys (Critical C-1)
- `formatDateOrRelative` reads the non-pluralised legacy keys (`relativeMinutesAgo` / `relativeHoursAgo`)
The two helpers ship together in the same release with deliberately different i18n contracts, and the doc comment on `formatDateOrRelative` (`src/lib/format.ts:90`) even claims it "reuses the `insights.relative*` i18n keys (same keys `formatRelativeTime()` reads)" — which is no longer true after the W4 plural commit.
**Files involved:**
- `src/lib/format.ts:73-122` — `formatDateOrRelative`
- `src/lib/i18n/relative-time.ts:16-49` — `formatRelativeTime`
**Suggested fix:** collapse into one helper that takes a `{ daysAsRelative: boolean }` option (or two thin wrappers around a shared core). Whichever pluralisation contract wins from C-1 should be the only one in the codebase.
### M-2. `WMY_FANOUT_CONCURRENCY` + `computeAvg30LastYearMap` exports exist only for tests
**Symptom:** v1.4.43 widened these two from module-private to `export` so a new concurrency test could pin the 4-cap (`src/lib/analytics/__tests__/summaries-slice.wmy-cap.test.ts`). Neither is consumed by production code outside `summaries-slice.ts` itself.
**Files involved:**
- `src/lib/analytics/summaries-slice.ts:744, 747` — `export const WMY_FANOUT_CONCURRENCY = 4;` + `export async function computeAvg30LastYearMap`
- `src/lib/analytics/__tests__/summaries-slice.wmy-cap.test.ts:26-27` — sole consumer
**Suggested fix:** if `knip` is set to enforcing mode (it is in v1.4.42 W-KNIP), `knip` may not flag test-only exports — verify CI doesn't break, then add an `// @internal — exported for the WMY-cap unit test only` comment so a future contributor doesn't reuse them by accident. Alternatively use `vi.spyOn(module, "computeAvg30LastYearMap")` against the private symbol via `vi.importActual` — same outcome, no public-surface widening.
### M-3. `__test_SECTION_ORDER` duplicates the private `SECTION_ORDER` instead of exporting it
**Symptom:** `src/components/doctor-report/doctor-report-dialog.tsx:513-521` exports a hand-duplicated array `__test_SECTION_ORDER` for an SSR component test, while `SECTION_ORDER` at line 65 is private. Two arrays that must stay in lockstep with no compile-time link.
**Files involved:**
- `src/components/doctor-report/doctor-report-dialog.tsx:65-73` — private `SECTION_ORDER`
- `src/components/doctor-report/doctor-report-dialog.tsx:513-521` — exported duplicate `__test_SECTION_ORDER`
**Suggested fix:** export `SECTION_ORDER` directly and have the test import it. The `__test_` prefix is fine documentation that it is also test-touched, but the duplicate value is regression bait.
### M-4. `withings/resume/route.ts` POST handler takes `request` then `void request;`s it
**Symptom:** `src/app/api/integrations/withings/resume/route.ts:35-58` accepts `request: NextRequest` and immediately calls `void request;`. The handler does not read the body, headers, IP, or URL — `requireAuth()` does its own request-scoping. The parameter exists only because `apiHandler` types it; rather than wrapping with a no-arg arrow + casting, the cleaner shape is to drop the parameter or rename it `_request`.
**Files involved:**
- `src/app/api/integrations/withings/resume/route.ts:35` — `void request;`
**Suggested fix:** rename to `_request` (TS / lint-allowed unused-prefix) or check whether `apiHandler` accepts a 0-arg handler. Either drops the `void` magic incantation.
### M-5. `health-chart.tsx` stashes raw count on the data array via `Object.defineProperty` — unusual carrier
**Symptom:** `src/components/charts/health-chart.tsx:691-697` writes a non-enumerable `rawCount` property onto the returned data array, then `src/components/charts/health-chart.tsx:704-708` reads it back via a type-coerced cast. The data array is the value the `useQuery` cache stores, so the property has to survive React-Query's structural sharing pass (it does — non-enumerable properties aren't seen by the cloner — but the test surface for this is silent). Easier: return `{ data: ChartDataPoint[]; rawCount: number }` and adjust the one consumer.
**Files involved:**
- `src/components/charts/health-chart.tsx:691-708` — `Object.defineProperty(allData, "rawCount", …)` + `useMemo` re-read
**Suggested fix:** switch the query function to return `{ data, rawCount }`; rename the `useQuery` destructure to `{ data: queryResult }` then read `queryResult?.data` and `queryResult?.rawCount`. Drops the non-enumerable trick and removes the `(data as ChartDataPoint[] & { rawCount?: number })` cast.
---
## Low
### L-1. `scrollBehaviorForUser` not wired into the fourth caller it advertises
**Symptom:** `src/lib/motion.ts:1-3` claims to be the source of truth for "the four programmatic `scrollIntoView` / `scrollTo` call-sites in the app", but `src/components/onboarding/WelcomeCarousel.tsx:122-126` still inlines its own `window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"` check. Three of the advertised four call-sites use the helper; one was missed.
**Files involved:**
- `src/lib/motion.ts:1-3` — comment promises "the four call-sites"
- `src/components/onboarding/WelcomeCarousel.tsx:122-126` — inline copy not switched over
**Suggested fix:** wire `WelcomeCarousel.tsx` through `scrollBehaviorForUser()`, or amend the motion.ts header comment to "three call-sites".
### L-2. `audit.coach.replay-injection` audit action name is anomalous (no other audit action uses the `audit.` prefix)
**Symptom:** Every other audit action name in the codebase reads `<surface>.<verb>` (e.g. `admin.backups.run`, `auth.login.failed`, `integrations.parked`, `integrations.resumed`). The new replay-injection guard writes `audit.coach.replay-injection` (with a `.` separator on `replay-injection` to boot), which both leaks the storage layer's name and breaks the dot-separator convention.
**Files involved:**
- `src/app/api/insights/chat/route.ts:217` — `await auditLog("audit.coach.replay-injection", { … });`
**Suggested fix:** rename to `coach.replay.detected` (matches `auth.login.failed` shape) or `insights.coach.replay_injection` (matches the `annotate()` action name two lines above it). Either drops the `audit.` prefix duplication.
### L-3. `OfflineBanner` uses `queueMicrotask` as an escape hatch for a strict lint rule — `useSyncExternalStore` is the documented pattern
**Symptom:** `src/components/layout/offline-banner.tsx:52-56` wraps the initial `navigator.onLine` read inside a `queueMicrotask` "because the legacy in-effect setState path tripped the strict lint rule". This works but `useSyncExternalStore` is the idiomatic shape for "subscribe to an external boolean signal during render" — no microtask defer, no hydration mismatch, no comment needed.
**Files involved:**
- `src/components/layout/offline-banner.tsx:25-61` — useState + useEffect + queueMicrotask shape
**Suggested fix:** rewrite as `useSyncExternalStore` with subscribe = add/remove online/offline listeners, getSnapshot = `navigator.onLine`, getServerSnapshot = `true`. Drops both the `queueMicrotask` ceremony and the SSR-stability comment.
### L-4. `Object.defineProperty(allData, "rawCount", {writable: false})` + `mood-chart.tsx` per-day reduction are inconsistent — one chart smuggles count via the array, the other computes from raw data
**Symptom:** v1.4.43 W2-CHART-GATE applied the "split copy on raw count vs distinct days" fix across three charts:
- `health-chart.tsx` smuggles `rawCount` via `Object.defineProperty` (M-5 above)
- `medication-compliance-chart.tsx:255-269` computes from `data.reduce((acc, p) => acc + p.scheduled, 0)` (clean)
- `mood-chart.tsx` (verify) likely matches the compliance pattern
The three implementations should share one shape. The compliance shape is cleaner; the health-chart shape is the outlier.
**Files involved:**
- `src/components/charts/health-chart.tsx:691-708`
- `src/components/charts/medication-compliance-chart.tsx:255-269`
- `src/components/charts/mood-chart.tsx` (verify same)
**Suggested fix:** rework `health-chart.tsx` to compute `rawCount` from the same `.reduce` shape (sum across daily aggregates' `count` field) after the query resolves, instead of stashing it on the array during the fetch.
---
## Out of scope / not flagged
- `formatActiveLocale` widening in `src/lib/format.ts:9-19` (legitimate v1.4.43 QoL — sub-locale users now see proper locale formatters)
- `recordWithingsSyncFailure` export change (`src/lib/withings/sync.ts:290`) — needed to let `sync-activity.ts` + `sync-sleep.ts` route through the typed classifier (W7-B3); not over-engineering
- `WithingsApiError` 1024-char message cap (`src/lib/withings/response-classifier.ts:248-254`) — defence-in-depth, appropriate
- `checkAuthSurfaceRateLimit` (`src/lib/rate-limit.ts:104-127`) — clean abstraction over a real attack surface (trust-violation bucket exhaustion)
- `IntegrationStatus.consecutiveFailuresByKind` JSONB column + 24 h park flip — appropriate complexity for the contract-mismatch sustain problem
- `scripts/generate-sw-version.mjs` + `prebuild` — closes the v1.4.38.4 → v1.4.42 stale-CACHE_VERSION drift; correct shape
- Dockerfile `NEXT_PUBLIC_APP_VERSION` build-arg — closes the v1.4.42 stale `package.json` cache regression; correct shape
- W6 `returnAllZodIssues` rollout — straight-line API consistency
- Chart skeleton + dashboard tile-strip skeleton additions — appropriate UX polish
- All `motion-reduce:animate-none` additions + the textual-scan coverage test — appropriate accessibility floor
- All test additions are appropriate scaffolding; none pin temporary scaffolding instead of release-quality contracts
---
## Relevant files
- `/Users/marc/Projects/HealthLog/src/lib/i18n/relative-time.ts` (C-1)
- `/Users/marc/Projects/HealthLog/messages/{de,en,es,fr,it,pl}.json` (C-1)
- `/Users/marc/Projects/HealthLog/src/components/settings/integration-status-pill.tsx` (H-1)
- `/Users/marc/Projects/HealthLog/src/components/settings/integrations-section.tsx` (H-1)
- `/Users/marc/Projects/HealthLog/src/app/api/dashboard/chart-overlay-prefs/route.ts` (H-2)
- `/Users/marc/Projects/HealthLog/src/app/api/dashboard/widgets/route.ts` (H-2 reference)
- `/Users/marc/Projects/HealthLog/src/lib/format.ts` (M-1)
- `/Users/marc/Projects/HealthLog/src/lib/analytics/summaries-slice.ts` (M-2)
- `/Users/marc/Projects/HealthLog/src/components/doctor-report/doctor-report-dialog.tsx` (M-3)
- `/Users/marc/Projects/HealthLog/src/app/api/integrations/withings/resume/route.ts` (M-4)
- `/Users/marc/Projects/HealthLog/src/components/charts/health-chart.tsx` (M-5, L-4)
- `/Users/marc/Projects/HealthLog/src/lib/motion.ts` (L-1)
- `/Users/marc/Projects/HealthLog/src/components/onboarding/WelcomeCarousel.tsx` (L-1)
- `/Users/marc/Projects/HealthLog/src/app/api/insights/chat/route.ts` (L-2)
- `/Users/marc/Projects/HealthLog/src/components/layout/offline-banner.tsx` (L-3)
+104
View File
@@ -1,5 +1,109 @@
# Changelog
## [1.4.45] — 2026-05-21 — Analytics 9 s perf fix, audit-driven polish, Zod multi-issue rollout, Withings parked-state automation
> Version note — v1.4.43 was skipped: v1.4.44 shipped as a same-day REG-11 iOS hotfix on `main` while this marathon was running (REG-11 = Home dashboard tile renders neither chart nor latest value when the most recent reading is older than 7 days; root cause was in `/api/dashboard/summary` SQL gates). The REG-11 fix is included in this release alongside the marathon work. v1.4.45 keeps the version monotone above the hotfix tag.
v1.4.42 closed the iOS-readiness story. v1.4.45 is the post-deploy discovery + closure release: a four-axis audit round (analytics perf / mobile-UI / QoL / security) surfaced one Critical `/api/analytics` 9 s regression that had been latent since v1.4.40, five High mobile-UI WCAG paper-cuts, two PII / log-growth gaps on the security surface, six High QoL copy + i18n gaps, and the chart empty-state false-positive raised after deploy. Eleven implementation waves landed: nine close every audit Critical + High plus the recurring polish items (chart-gate raw-count, QoL copy + plural forms, Withings classifier wiring across both sync paths, ops hardening including the BuildKit version-pin lesson from v1.4.42), one rolls out the v1.4.42 W2-ZOD `returnAllZodIssues` helper to the 41 sibling routes that still dropped every issue past the first, and one closes the v1.4.42 W2-WITHINGS deferred B4 (park after 24 h persistent-failure streak) + B7 (per-kind failure counters). Three companion waves close the audit Mediums + Lows for mobile-UI, QoL, and security.
Fourteen touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W*-v1443-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1443-QA-*-findings.md` covers the cross-cutting verdict that gated this tag; the audit reports under `.planning/round-v1443-AUDIT-*-findings.md` document the discovery phase that fed the marathon.
### Added
- **`returnAllZodIssues` rolled out across 41 API routes** (W6) — every measurements / medications / mood / auth / settings / admin / consent / bugreport / feedback / device / token / ingest route now returns every Zod issue under `details.issues` plus a sanitised `{ path, code, message }` projection. iOS-contract hot paths additionally write a `<route>.validation-failed` audit-ledger breadcrumb (fire-and-forget). The CSV-import outlier (`/api/medications/[id]/intake/import`) preserves its `Invalid format:` prefix via `meta.errorCode = "medication.intake.import.invalid_format"`. 220 new unit cases pin 2-issue / 3-issue scenarios per touched route.
- **Withings persistent-failure park automation** (W14) — a `persistent` failure streak running > 24 h flips `IntegrationStatus.state` to a new `parked` sentinel. Settings card renders a pill `data-state="parked"` with copy "Pausiert — manuell wieder verbinden" (DE) / "Paused — reconnect manually" (EN) plus a "Wieder verbinden" / "Reconnect" CTA. New endpoint `POST /api/integrations/withings/resume` (rate-limited 5/min/user, idempotent) clears the park via `resumeIntegrationFromPark` and the per-card status query invalidates so the pill flips back without a refresh. Schema migration `0075_v1443_integration_park` adds the `consecutive_failures_by_kind` JSONB column + `persistent_failure_started_at` timestamp; backfill on next write bucketises the legacy single-counter into the current `FailureKind`.
- **`POST /api/auth/check-user` audit-ledger breadcrumb** (W13 M-1) — every branch (`found` / `not_found` / `passkey_only` / `email_fallback`) now writes `auditLog("auth.check-user", { ipAddress, details: { branch, identifier_hash } })` with the identifier hashed via `hashToken`. The route's per-IP throttle (30/15 min) stays; the audit row closes the operator-grep gap for enumeration-attempt investigations.
- **`/api/settings/account` DELETE cascade** (W12 M3) — split the danger-zone "Konto vollständig löschen" from the existing "Alle Gesundheitsdaten löschen" so a user reading "Gefahrenzone" reaches the right destructive action. New endpoint cascades User + sessions + passkeys + audit log under a Prisma tx; concurrent active sessions are invalidated synchronously. Six new integration tests pin the happy path + sibling-session invalidation + 401 / 422 / last-admin guard.
- **`<OfflineBanner>` mounted in `<AuthShell>`** (W12 M5) — listens for `online` / `offline` window events and renders "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist". Bilingual via i18n. Closes the PWA "blank tile + no explanation when airplane-mode toggles mid-form-fill" paper-cut.
- **`<ChartSkeleton>` 3 s "still computing" caption** (W12 L4) — after 3 s of skeleton paint a small caption renders to set user expectation when `/api/analytics` is on the slow path. Bilingual.
- **`<AccountDeleteCard>` + neutral danger-zone visuals** (W12 M3 + L5) — `AlertTriangle` icon dropped, title in neutral grey, button stays red. Same protective gate, less visual hostility.
- **`formatDateOrRelative(iso)` helper** (W12 L8) — within 24 h renders relative ("vor 12 min"), older renders absolute. Wired into `measurement-list.tsx:572,717` so adjacent timestamps no longer mix formats.
- **`scrollBehaviorForUser()` helper in `src/lib/motion.ts`** (W5 H5) — reads `prefers-reduced-motion: reduce` and returns `"auto"` or `"smooth"`. Wired into the four JS-driven smooth-scroll sites (`settings-shell`, `admin-shell`, `coach-panel/message-thread` × 2).
- **`charts.noDataInRangeTitle` + `charts.noDataInRangeDescription`** copy across all six locales (W11 M6). Replaces the silent `return null` branch in `<HealthChart>` with a proper `<ChartEmptyState>` so the dashboard layout doesn't reflow when a chart enters its empty branch.
- **`charts.needMoreDistinctDaysTitle` + `charts.needMoreDistinctDaysDescription`** copy across all six locales (W2 C1). Distinguishes the "few raw measurements" branch from the "lots of data, only on 1-2 days" branch on the chart's empty state.
- **`<DailyBriefing>` / `<CorrelationRow>` / `<TrendsRow>` skeleton heights reserved** to match loaded content (W11 M3) — `h-[24rem]` for DailyBriefing, `h-[20rem]` for the others. Closes the CLS hot-spot on cold mounts.
- **`<DashboardTileStripSkeleton>`** painted when `trendCards.length === 0 && analyticsSlimQuery.isLoading && layout.widgets.some(w => w.tileVisible)` (W11 M5). User sees the strip silhouette during the slow window instead of header + empty page + 9 s jump.
### Changed
- **`/api/analytics` slim slice `computeAvg30LastYearMap` capped at `p-limit(4)`** (W1) — closes the 9 s regression introduced in v1.4.40 W-WMY-WIRE. The unbounded `Promise.all` over 15 measurement types drowned the `pg.Pool` max=20 even after v1.4.40's W-POOL raise; capping at 4 collapses the burst to match the thick route's existing `ANALYTICS_TYPE_FETCH_CONCURRENCY=4`. Expected dashboard cold-mount: 9.0 s → 2-3 s (concurrent slim + thick both cold) / ~50 ms (warm cache); WMY-converged tenants ≤ 500 ms cold. The slim slice's per-(userId, date) cache key is unchanged; concurrent dashboards still single-flight per slice.
- **Withings activity + sleep sync catch-blocks routed through `WithingsApiError` typed classifier** (W7) — both `sync-activity.ts:174` and `sync-sleep.ts:155` previously caught raw thrown values + branched on `Error.message` string-matching. Now they typed-cast through `WithingsApiError` so a non-Error rejection lands in the `unknown` branch instead of silently sliding into `error_transient`. A typed-classification regression suite (`src/lib/withings/__tests__/sync-typed-classification.test.ts`) pins both paths.
- **`IntegrationStatusPill` warning state for `persistent` failures** (W4 H3) — v1.4.42's classifier introduced the fourth FailureKind but mapped it to `error_transient` so the pill read "Fehler — neu verbinden" identically to a transient failure. New "warning" pill state with copy "Verbunden, aber Serverfehler" gives the operator + user the signal that the access token still works but the upstream is responding with `601` / `293` / `294`.
- **`auth.check-user` rate-limit wrapper + 6 auth routes converted** (W13 M-4) — `checkAuthSurfaceRateLimit` wraps `checkRateLimit` and routes anonymous-bucket trust-violations to a tight 100/15-min global bucket instead of every misconfigured anonymous request collapsing into one shared `unknown` bucket. Applied to `/api/auth/login`, `/api/auth/register`, `/api/auth/passkey/login-options`, `/api/auth/passkey/login-verify`, `/api/auth/refresh`, `/api/auth/check-user`, `/api/auth/password`.
- **Coach SSE re-detects refusal on replayed turns** (W13 M-3) — every prior turn loaded from `coach_messages.encryptedContent` now runs through `detectRefusal` before re-entering the prompt. A positive hit short-circuits the SSE with a refusal response + emits an `audit.coach.replay-injection` audit row. Closes the "false-negative amplification" path where a regex-bank miss on turn N kept re-entering on every subsequent turn.
- **`/api/auth/passkey/login-verify` body now Zod-narrowed** (W13 L-3) — replaces the raw `as AuthenticationResponseJSON` cast with an explicit Zod parse upstream of `verifyAuthentication`. Closes the type-narrowing gap a future refactor could trip on; the verifier remains the runtime owner.
- **`activeLocale()` reads the full `Locale` union** (W12 M8) — sub-locale users (fr / es / it / pl) now get their native number + date formats from `Intl.DateTimeFormat` instead of the `en` fallback. The legacy `format.ts` helper backed ~25 SSR + audit-log call sites; the new branch falls back to `en` only when the cookie is unrecognised.
- **404 + global-error copy tightened across all locales** (W12 L1) — "Page not found / The page you were looking for doesn't exist or has been moved." → "Diese Seite existiert nicht." / "This page doesn't exist." period. Bilingual lockup retained on `global-error.tsx` (root layout has failed by the time this paints — no i18n provider).
- **`relativeMinutesAgo` / `relativeHoursAgo` / `relativeDaysAgo` keys split into `…One` / `…Other`** (W4 H6) — `count === 1` now renders the singular form across all six locales. The integration pill abbreviated forms (`vor 1 min` / `vor 1 d`) remain.
- **`coach.network` mapped to its own `errorNetwork` i18n key** (W12 M6) — distinct copy from the provider-error bucket so a user sees "Keine Internetverbindung" when their network is dead vs the original generic provider-unreachable copy when the provider itself is down.
- **`DayDrillDown` error renders `measurements.loadError` instead of `measurements.saveError`** (W4 H1) — closes the "Save error" misattribution on GET-only failures.
- **Switch tap target extends to 44 × 44 via `::before` pseudo-element** (W5 H1) — every Settings / sources / dashboard-layout / coach-settings / notification toggle now meets WCAG 2.5.5 without changing the visual.
- **Comparison-baseline buttons in chart overlay popover bump to 44 px on mobile** (W5 H2) — `min-h-11 sm:min-h-9` so phones get the safer target without densifying desktop.
- **Mood-form kebab + edit-mode footer kebab bump to 44 × 44** (W5 H3) — `h-11 w-11` to match the medication-form pattern.
- **Bottom-sheet close-X reaches 44 px on phones, keeps 36 px on desktop** (W5 H4) — `min-h-9 sm:min-h-11` gated on breakpoint.
- **`Loader2 animate-spin` paired with `motion-reduce:animate-none` across 21 sites** (W11 M1) — closes the motion-sensitivity gap on chart loading + settings spinners + admin backup runners + drug-level + doctor-report dialogs.
- **`phase-config-dialog.tsx` Input + Button bump to 44 px on mobile** (W11 M2) — drops `text-sm` from the Input so the iOS-zoom-on-focus is closed.
- **`correlation-card.tsx` skeleton mirrors the loaded scatter chart's aspect ratio** (W11 M4) — `aspect-square sm:aspect-[3/2] min-h-[180px] sm:h-auto`. Closes the ~60 px CLS on the insights cold mount.
- **`insights-tab-strip.tsx` group-popover items bumped to 44 px** (W11 L1) — outer tab pills already met the floor; inner popover items lagged.
- **`chart-overlay-controls.tsx` trigger adopts the responsive `min-h-11 sm:min-h-9` pattern** (W11 L2) — consistent with the v1.4.33 maintainer-item-7 settlement.
- **`RecentWorkoutsTile` + `DrugLevelChart` reserve loaded-card height during loading** (W11 L6) — no card-pop on first paint.
- **`dedupeWorkoutBatch` honours `User.sourcePriorityJson`** (W9) — write-time picker threads the same priority ladder the read-time picker uses. The v1.4.42 wave's docstring warning ("does NOT consult sourcePriorityJson") is now obsolete; the batch route does a single `prisma.user.findUnique` lookup before the dedup pass.
- **`/api/dashboard/widgets` 422 audit-ledger row dedupes per `(userId, action)` over 60 s** (W8 B2) — a misconfigured iOS client retrying every 100 ms no longer writes 10 audit rows per second. The 422 response is still per-call; only the audit-ledger row dedupes.
### Fixed
- **`auth.login.failed` audit row no longer persists the raw typed identifier** (W3 H-1) — replaced with `identifier_hash: hashToken(identifier)`. Closes the PII gap surfaced by the v1.4.20 retroactive directive: an admin scrolling `/api/admin/audit-log` no longer sees every wrong-email a user typo'd at the login screen, and a future audit-table compromise no longer hands an attacker a list of probed identifiers.
- **`WithingsApiError.message` capped at 1024 chars in the constructor** (W3 H-2) — the v1.4.42 classifier persisted the upstream `json.error` string verbatim into `AuditLog.details`. Withings is the trusted upstream but the operator-readable audit row should match the encrypted-error column's 1024-char cap.
- **Chart empty-state copy split on raw measurement count** (W2 C1) — `<HealthChart>`, `<MoodChart>`, `<MedicationComplianceChart>` now distinguish "fewer than 3 entries" (empty-state title) from "many entries but only on 1-2 distinct days" (new `needMoreDistinctDays` title). The user-reported "Erfasse 3 Einträge" on a populated account is closed.
- **Chart `aria-busy` empty branch no longer silently returns null** (W11 M6) — replaced with a `<ChartEmptyState>` rendering the `noDataInRange` copy.
- **`docker-publish.yml` bakes the build-tag into the image at build time** (W8 B11) — closes the BuildKit cache version-stale paper-cut from v1.4.42.
- **`pnpm check-env` enforces manifest ↔ `.env.production.example` lockstep on CI** (W8 B5).
- **`global-error.tsx` bilingual lockup** (W4 H5 + W12 L1 follow-up).
- **`not-found.tsx` localised via `getServerTranslations`** (W4 H4).
- **German `daysAgo` integration-pill abbreviation** (W12 M7) — "vor {count} T." → "vor {count} d" matches the rest of the app's stale-hint pattern.
- **SW `CACHE_VERSION` re-anchors per build** (W12 L3) — `public/sw.js`'s literal `"v1.4.38.4"` had drifted four releases stale; the activate-step's old-cache eviction had been a no-op. A new `prebuild` script writes `public/sw-version.js` from `package.json`'s version + `importScripts('/sw-version.js')` re-anchors `CACHE_VERSION` per deploy.
- **Onboarding checklist measurement-completion copy** (W12 L2).
- **Doctor-report unavailable sections render disabled instead of vanishing** (W12 M4).
- **`auth/login + auth/register` test mocks updated for `checkAuthSurfaceRateLimit`** (post-cherry-pick reconcile) — W6's new test files pinned the old `checkRateLimit` signature; W13's M-4 wrapper rename collided at runtime causing 500 instead of 401 / 422. Both factories + `beforeEach` arming updated.
### Performance
Anchored on the v1.4.43 W1-ANALYTICS-PERF audit (`.planning/round-v1443-AUDIT-analytics-9s-findings.md`).
- **Dashboard cold-mount `/api/analytics` (concurrent slim + thick):** 9.0 s + 9.0 s in parallel → est. 2-3 s + 2-3 s in parallel (slim slice fan-out capped at `p-limit(4)`). Warm cache (60 s TTL hit) unchanged at ~50 ms. WMY-converged tenants ≤ 500 ms cold.
### Operator notes
- **Schema migration required**: `prisma/migrations/0075_v1443_integration_park/migration.sql` extends `IntegrationStatus` with `consecutive_failures_by_kind` (JSONB) + `persistent_failure_started_at` (TIMESTAMP). The migration is idempotent (`IF NOT EXISTS` guards on both columns) and reversible (backfill is non-destructive — the legacy single-counter column stays for one release as a fallback). Run `prisma migrate deploy` post-deploy; no downtime expected.
- **No env-var change.** `pnpm check-env` (v1.4.42) still passes; the new env-check CI gate enforces manifest ↔ `.env.production.example` lockstep.
- **No API contract break for iOS v0.5.4.** `returnAllZodIssues` envelope is additively extended (existing `error` field preserved); iOS clients that hard-coded `body.error` keep working. New endpoints (`/api/integrations/withings/resume`, `/api/settings/account` DELETE) are additive.
- **Withings persistent-failure park** flips `IntegrationStatus.state` to `"parked"` after > 24 h of consecutive `persistent` failures (Withings `601` / `293` / `294`). A parked integration's next scheduled sync is short-circuited until the user clicks "Wieder verbinden" (rate-limited 5/min/user). Operator can also call the resume endpoint manually for stuck accounts.
- **Build pipeline**: `docker-publish.yml` now bakes `NEXT_PUBLIC_APP_VERSION` at build time so `/api/version` can never serve a stale version after a Coolify cache re-use. Verify `/api/version` post-deploy as usual.
- `pnpm test --run` green at 5076 passing / 1 skipped (5077 total), up from 4815. `pnpm typecheck`, `pnpm lint`, `pnpm knip` (enforcing) all green. Two pre-existing integration-tier failures in `tests/integration/workout-batch-{create,race}.test.ts` reproduce on `origin/main` unchanged and are NOT caused by this release; flagged for v1.4.44 investigation.
## [1.4.44] — 2026-05-21 — REG-11 dashboard summary hotfix (iOS Home tile sparkline + value)
iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neither chart nor latest value for BP / Puls / Körperfett when the most recent reading was older than 7 days. Root cause was in `/api/dashboard/summary` SQL gates — both `latestIn7d` and `sparkBuckets` required `measured_at >= sevenDaysAgo`, which returned empty for sparse accounts. The iOS tile rendered from `latestValue: null` + `sparkline: []`. Five iOS-side attempts had been wrong because the bug was server-side. v1.4.44 was a same-day same-author hotfix on `main` while the v1.4.45 audit-marathon was running; the marathon work is preserved in v1.4.45.
### Fixed
- **`/api/dashboard/summary` `latestIn7d` → `latestEver`**: drops the 7-day filter; `DISTINCT ON (type)` returns the latest reading EVER per type, bounded at N-metrics rows. Sparse accounts (one BP reading 60 days ago, nothing since) now get the tile back.
- **`sparkBuckets` rewritten via `ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start DESC)`**: trailing N daily buckets per type render regardless of calendar age, bounded at SPARK_DAYS × N-metrics.
- **BP + Pulse `metrics.push` gated on `latest || allTimeCount > 0`** like BodyFat already was — accounts with zero readings ever no longer get empty placeholder tiles.
### Tests
Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11:
- BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline`
- BP with null readings ever → tile NOT emitted
- Weight within 7 days → behaviour unchanged (regression guard)
- Sparkline window picks up 7 daily buckets even if all older than 7 calendar days
### Operator notes
- No migration. No env-var change. No API contract break for iOS v0.5.4.
- v1.4.43 was skipped; v1.4.45 carries the audit-marathon closure.
## [1.4.42] — 2026-05-21 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader-tier completeness story. v1.4.42 is the follow-up polish-and-iOS-readiness release: the knip CI gate flips to enforcing-mode (zero unused exports + zero unused types on `main`), the long-tail queryKey factory migration closes the v1.4.40 audit-H1 contract for the settings / medications / admin / hooks surface, the `/api/dashboard/widgets` 422 returns every Zod issue so the next iOS contract-debugging session takes one round-trip instead of one per wrong field, Withings off-responses are classified into transient / reauth / persistent (rate-limit-induced `601`s no longer silently retry forever), the iOS HealthKit ingest gets a cross-source workout dedup helper that lets Apple Watch + Withings ScanWatch paired captures collapse to one row at write time, and a tree-hygiene wave lands (BERLIN_DAY_FORMATTER consolidated across nine call sites, Suspense double-comment consolidation, doctor-report-data byte-escape so diffs become readable, pr-detection-worker soft-delete filter, offhost-backup DR-intent comment).
+34 -17
View File
@@ -145,19 +145,20 @@
"moodSensitive": "Sensibel — standardmäßig aus.",
"compliance": "Medikamenten-Compliance",
"sleep": "Schlafdauer",
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt."
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
"unavailableHint": "Keine Daten in diesem Zeitraum"
}
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
"description": "Ein unerwarteter Fehler ist aufgetreten. Du kannst es erneut versuchen, die technischen Details kopieren oder einen Bug-Report öffnen."
},
"errors": {
"notFound": {
"title": "Seite nicht gefunden",
"body": "Die gesuchte Seite existiert nicht oder wurde verschoben. Kehr zum Dashboard zurück, um dort weiterzumachen, wo du aufgehört hast.",
"backToDashboard": "Zurück zum Dashboard"
}
"notFound": {
"title": "Diese Seite existiert nicht.",
"backToDashboard": "Zurück zum Dashboard"
},
"offlineBanner": {
"message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist."
},
"nav": {
"dashboard": "Dashboard",
@@ -353,7 +354,6 @@
"notesPlaceholder": "z.B. nach dem Essen",
"formReset": "Formular zurücksetzen",
"saveError": "Fehler beim Speichern",
"loadError": "Fehler beim Laden",
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
"deleteError": "Fehler beim Löschen",
"invalidValue": "Bitte einen gültigen Wert eingeben.",
@@ -797,6 +797,7 @@
},
"charts": {
"loadingLabel": "Diagramm wird geladen…",
"loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
"pulse": "Puls",
@@ -862,6 +863,8 @@
"emptyStateDescription": "Erfasse mindestens 3 Einträge, um die Trendlinie freizuschalten.",
"needMoreDistinctDaysTitle": "Mehr Messtage erforderlich",
"needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.",
"noDataInRangeTitle": "Keine Daten in diesem Zeitraum",
"noDataInRangeDescription": "Für den gewählten Bereich liegen keine Messungen vor. Wechsle den Zeitraum oder erfasse einen neuen Wert.",
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
@@ -1070,6 +1073,7 @@
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
"send": "Senden",
@@ -1143,8 +1147,8 @@
"feedbackThanks": "Danke für das Signal.",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
"providerRateLimitTitle": "Anbieter gedrosselt",
"providerRateLimitBody": "Anbieter ist temporär überlastet; wieder verfügbar in ~5 min."
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min."
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -1520,7 +1524,7 @@
"profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.",
"profileCta": "Profil öffnen",
"measurementTitle": "Erste Messung anlegen",
"measurementDescription": "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen.",
"measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag.",
"measurementCta": "Messung hinzufügen",
"medicationTitle": "Medikament hinzufügen",
"medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
@@ -1629,7 +1633,7 @@
},
"ai": {
"title": "Auswertungen",
"description": "Anbieter, Modell, API-Schlüssel."
"description": "Provider, Modell, API-Key."
},
"api": {
"title": "API & Tokens",
@@ -1767,9 +1771,9 @@
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
"rawData": "Rohdaten mitsenden",
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit der Anbieter zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, der Anbieter kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
"rawDataWarning": "Rohdaten aktiv: Der Anbieter erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.",
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit der Provider zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, der Provider kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
"rawDataWarning": "Rohdaten aktiv: Der Provider erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.",
"regenerateInsights": "Reports neu generieren",
"regenerateSuccess": "Reports wurden neu generiert",
"regenerateRateLimit": "Bitte warte — du hast das stündliche Limit für Analysen erreicht.",
@@ -1903,12 +1907,16 @@
"connected": "Verbunden",
"errorReconnect": "Fehler — neu verbinden",
"warningServerError": "Verbunden, aber Serverfehler",
"parkedReconnect": "Pausiert — manuell wieder verbinden",
"notConnected": "Nicht verbunden",
"justNow": "gerade eben",
"minutesAgo": "vor {count} min",
"hoursAgo": "vor {count} Std.",
"daysAgo": "vor {count} T.",
"ariaLabel": "Integrationsstatus"
"daysAgo": "vor {count} d",
"ariaLabel": "Integrationsstatus",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen"
},
"apiTokens": "API-Tokens",
"apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).",
@@ -1938,12 +1946,21 @@
"collapse": "Einklappen",
"expand": "Ausklappen",
"dangerZone": "Alle Daten löschen",
"dangerZoneTitle": "Gefahrenzone",
"dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Benutzerkonto bleibt erhalten.",
"dangerZoneConfirm": "Alle Daten löschen?",
"dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Benutzerkonto bleibt erhalten.",
"dangerZoneSuccess": "Alle persönlichen Daten wurden gelöscht",
"dangerZoneDeleteFailed": "Löschen fehlgeschlagen",
"finalDelete": "Endgültig löschen",
"deleteAccountCardTitle": "Konto vollständig löschen",
"deleteAccountCardDescription": "Löscht dein Konto inklusive Passkeys, Audit-Log und Sessions. Dieser Vorgang kann nicht rückgängig gemacht werden.",
"deleteAccountCta": "Konto löschen",
"deleteAccountConfirmTitle": "Konto endgültig löschen?",
"deleteAccountConfirmDescription": "Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet.",
"deleteAccountSuccess": "Konto wurde gelöscht — du wirst abgemeldet.",
"deleteAccountFailed": "Konto konnte nicht gelöscht werden",
"deleteAccountFinal": "Endgültig löschen",
"saved": "Gespeichert",
"savingError": "Fehler beim Speichern",
"moodLogTitle": "moodLog",
+27 -10
View File
@@ -145,19 +145,20 @@
"moodSensitive": "Sensitive — off by default.",
"compliance": "Medication compliance",
"sleep": "Sleep duration",
"empty": "No data was recorded in this date range yet. The report will still include the cover page."
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
"unavailableHint": "No data in this period"
}
},
"errorBoundary": {
"title": "Something went wrong",
"description": "An unexpected error occurred. You can retry, copy the technical details, or open a bug report."
},
"errors": {
"notFound": {
"title": "Page not found",
"body": "The page you were looking for doesn't exist or has been moved. Head back to the dashboard to pick up where you left off.",
"backToDashboard": "Back to dashboard"
}
"notFound": {
"title": "This page doesn't exist.",
"backToDashboard": "Back to dashboard"
},
"offlineBanner": {
"message": "No connection — your changes will save once you're back online."
},
"nav": {
"dashboard": "Dashboard",
@@ -353,7 +354,6 @@
"notesPlaceholder": "e.g. after a meal",
"formReset": "Reset form",
"saveError": "Error saving",
"loadError": "Could not load measurements",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"invalidValue": "Please enter a valid value.",
@@ -797,6 +797,7 @@
},
"charts": {
"loadingLabel": "Loading chart…",
"loadingSlowHint": "Computing analytics — this can take a moment.",
"weight": "Weight",
"bloodPressure": "Blood Pressure",
"pulse": "Pulse",
@@ -862,6 +863,8 @@
"emptyStateDescription": "Log at least 3 entries to unlock the trend line.",
"needMoreDistinctDaysTitle": "More distinct days needed",
"needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.",
"noDataInRangeTitle": "No data in this range",
"noDataInRangeDescription": "No measurements fall inside the selected range. Switch the range or log a new measurement.",
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
@@ -1070,6 +1073,7 @@
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
"errorNetwork": "No internet connection — try again once you're back online.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
"send": "Send",
@@ -1520,7 +1524,7 @@
"profileDescription": "Height, date of birth and gender unlock personalised targets.",
"profileCta": "Open profile",
"measurementTitle": "Log your first measurement",
"measurementDescription": "One reading is enough to draw your first trend tile.",
"measurementDescription": "One reading unlocks the tile — the trend itself draws from your 5th reading.",
"measurementCta": "Add measurement",
"medicationTitle": "Add a medication",
"medicationDescription": "Track compliance and get reminders right when you need them.",
@@ -1903,12 +1907,16 @@
"connected": "Connected",
"errorReconnect": "Error — reconnect",
"warningServerError": "Connected, server error",
"parkedReconnect": "Paused — reconnect manually",
"notConnected": "Not connected",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
@@ -1938,12 +1946,21 @@
"collapse": "Collapse",
"expand": "Expand",
"dangerZone": "Delete All Data",
"dangerZoneTitle": "Danger zone",
"dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
"dangerZoneConfirm": "Delete all data?",
"dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
"dangerZoneSuccess": "All personal data has been deleted",
"dangerZoneDeleteFailed": "Deletion failed",
"finalDelete": "Delete permanently",
"deleteAccountCardTitle": "Delete account entirely",
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.",
"deleteAccountCta": "Delete account",
"deleteAccountConfirmTitle": "Delete account permanently?",
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.",
"deleteAccountSuccess": "Account deleted — signing you out.",
"deleteAccountFailed": "Account could not be deleted",
"deleteAccountFinal": "Delete permanently",
"saved": "Saved",
"savingError": "Error saving",
"moodLogTitle": "moodLog",
+27 -10
View File
@@ -145,19 +145,20 @@
"moodSensitive": "Sensible — desactivado por defecto.",
"compliance": "Cumplimiento de medicación",
"sleep": "Duración del sueño",
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada."
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
"unavailableHint": "Sin datos en este periodo"
}
},
"errorBoundary": {
"title": "Algo ha salido mal",
"description": "Se ha producido un error inesperado. Puedes reintentar, copiar los detalles técnicos o abrir un reporte de bug."
},
"errors": {
"notFound": {
"title": "Página no encontrada",
"body": "La página que buscas no existe o se ha movido. Vuelve al panel para continuar donde lo dejaste.",
"backToDashboard": "Volver al panel"
}
"notFound": {
"title": "Esta página no existe.",
"backToDashboard": "Volver al panel"
},
"offlineBanner": {
"message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea."
},
"nav": {
"dashboard": "Panel",
@@ -353,7 +354,6 @@
"notesPlaceholder": "p. ej. después de comer",
"formReset": "Restablecer formulario",
"saveError": "Error al guardar",
"loadError": "No se han podido cargar las mediciones",
"duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.",
"deleteError": "Error al eliminar",
"invalidValue": "Introduce un valor válido.",
@@ -797,6 +797,7 @@
},
"charts": {
"loadingLabel": "Cargando la gráfica…",
"loadingSlowHint": "Calculando los análisis — puede tardar un momento.",
"weight": "Peso",
"bloodPressure": "Tensión arterial",
"pulse": "Pulso",
@@ -862,6 +863,8 @@
"emptyStateDescription": "Registra al menos 3 entradas para desbloquear la línea de tendencia.",
"needMoreDistinctDaysTitle": "Se necesitan más días distintos",
"needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.",
"noDataInRangeTitle": "Sin datos en este rango",
"noDataInRangeDescription": "No hay mediciones en el rango seleccionado. Cambia el rango o registra una nueva medición.",
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
"deltaUnchanged": "coincide con tu media",
@@ -1070,6 +1073,7 @@
"refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud — reformula tu pregunta dentro de ese marco.",
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
"errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.",
"tagline": "Coach personal de salud, apoyado en tus datos.",
"newChat": "Nueva conversación",
"send": "Enviar",
@@ -1520,7 +1524,7 @@
"profileDescription": "Altura, fecha de nacimiento y sexo desbloquean objetivos personalizados.",
"profileCta": "Abrir perfil",
"measurementTitle": "Registrar la primera medición",
"measurementDescription": "Basta una medición para trazar tu primera tarjeta de tendencia.",
"measurementDescription": "Una medición activa la tarjeta — la tendencia se dibuja a partir de la 5.ª medición.",
"measurementCta": "Añadir medición",
"medicationTitle": "Añadir medicamento",
"medicationDescription": "Sigue el cumplimiento y recibe recordatorios justo cuando los necesites.",
@@ -1903,12 +1907,16 @@
"connected": "Conectado",
"errorReconnect": "Error — reconectar",
"warningServerError": "Conectado, error del servidor",
"parkedReconnect": "Pausado — reconectar manualmente",
"notConnected": "No conectado",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
@@ -1938,12 +1946,21 @@
"collapse": "Collapse",
"expand": "Expand",
"dangerZone": "Delete All Data",
"dangerZoneTitle": "Danger zone",
"dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
"dangerZoneConfirm": "Delete all data?",
"dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
"dangerZoneSuccess": "All personal data has been deleted",
"dangerZoneDeleteFailed": "Deletion failed",
"finalDelete": "Delete permanently",
"deleteAccountCardTitle": "Delete account entirely",
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.",
"deleteAccountCta": "Delete account",
"deleteAccountConfirmTitle": "Delete account permanently?",
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.",
"deleteAccountSuccess": "Account deleted — signing you out.",
"deleteAccountFailed": "Account could not be deleted",
"deleteAccountFinal": "Delete permanently",
"saved": "Guardado",
"savingError": "Error saving",
"moodLogTitle": "moodLog",
+27 -10
View File
@@ -145,19 +145,20 @@
"moodSensitive": "Sensible — désactivé par défaut.",
"compliance": "Observance des médicaments",
"sleep": "Durée du sommeil",
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde."
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
"unavailableHint": "Pas de données sur cette période"
}
},
"errorBoundary": {
"title": "Une erreur est survenue",
"description": "Une erreur inattendue est survenue. Tu peux réessayer, copier les détails techniques ou ouvrir un rapport de bug."
},
"errors": {
"notFound": {
"title": "Page introuvable",
"body": "La page que tu cherches n’existe pas ou a été déplacée. Reviens au tableau de bord pour reprendre où tu en étais.",
"backToDashboard": "Retour au tableau de bord"
}
"notFound": {
"title": "Cette page n'existe pas.",
"backToDashboard": "Retour au tableau de bord"
},
"offlineBanner": {
"message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne."
},
"nav": {
"dashboard": "Tableau de bord",
@@ -353,7 +354,6 @@
"notesPlaceholder": "p. ex. après le repas",
"formReset": "Réinitialiser le formulaire",
"saveError": "Erreur lors de l’enregistrement",
"loadError": "Impossible de charger les mesures",
"duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.",
"deleteError": "Erreur lors de la suppression",
"invalidValue": "Saisis une valeur valide.",
@@ -797,6 +797,7 @@
},
"charts": {
"loadingLabel": "Chargement du graphique…",
"loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.",
"weight": "Poids",
"bloodPressure": "Tension artérielle",
"pulse": "Pouls",
@@ -862,6 +863,8 @@
"emptyStateDescription": "Saisis au moins 3 entrées pour débloquer la courbe de tendance.",
"needMoreDistinctDaysTitle": "Plus de jours distincts requis",
"needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.",
"noDataInRangeTitle": "Aucune donnée sur cette période",
"noDataInRangeDescription": "Aucune mesure ne tombe dans la plage sélectionnée. Change de plage ou saisis une nouvelle mesure.",
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
"deltaUnchanged": "correspond à ta moyenne",
@@ -1070,6 +1073,7 @@
"refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé — reformule ta question dans ce cadre.",
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
"errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.",
"tagline": "Coach personnel de santé, ancré sur tes données.",
"newChat": "Nouvelle conversation",
"send": "Envoyer",
@@ -1520,7 +1524,7 @@
"profileDescription": "Taille, date de naissance et sexe débloquent des objectifs personnalisés.",
"profileCta": "Ouvrir le profil",
"measurementTitle": "Saisir la première mesure",
"measurementDescription": "Une mesure suffit pour tracer ta première tuile de tendance.",
"measurementDescription": "Une mesure suffit à activer la tuile — la tendance se dessine à partir de la 5ᵉ mesure.",
"measurementCta": "Ajouter une mesure",
"medicationTitle": "Ajouter un médicament",
"medicationDescription": "Suis l’observance et reçois des rappels au moment où tu en as besoin.",
@@ -1903,12 +1907,16 @@
"connected": "Connecté",
"errorReconnect": "Erreur — reconnecter",
"warningServerError": "Connecté, erreur serveur",
"parkedReconnect": "En pause — reconnecter manuellement",
"notConnected": "Non connecté",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
@@ -1938,12 +1946,21 @@
"collapse": "Collapse",
"expand": "Expand",
"dangerZone": "Delete All Data",
"dangerZoneTitle": "Danger zone",
"dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
"dangerZoneConfirm": "Delete all data?",
"dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
"dangerZoneSuccess": "All personal data has been deleted",
"dangerZoneDeleteFailed": "Deletion failed",
"finalDelete": "Delete permanently",
"deleteAccountCardTitle": "Delete account entirely",
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.",
"deleteAccountCta": "Delete account",
"deleteAccountConfirmTitle": "Delete account permanently?",
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.",
"deleteAccountSuccess": "Account deleted — signing you out.",
"deleteAccountFailed": "Account could not be deleted",
"deleteAccountFinal": "Delete permanently",
"saved": "Enregistré",
"savingError": "Error saving",
"moodLogTitle": "moodLog",
+27 -10
View File
@@ -145,19 +145,20 @@
"moodSensitive": "Sensibile — disattivato per default.",
"compliance": "Aderenza ai farmaci",
"sleep": "Durata del sonno",
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina."
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
"unavailableHint": "Nessun dato in questo periodo"
}
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
"description": "Si è verificato un errore inatteso. Puoi riprovare, copiare i dettagli tecnici o aprire una segnalazione di bug."
},
"errors": {
"notFound": {
"title": "Pagina non trovata",
"body": "La pagina che cercavi non esiste o è stata spostata. Torna alla dashboard per riprendere da dove eri rimasto.",
"backToDashboard": "Torna alla dashboard"
}
"notFound": {
"title": "Questa pagina non esiste.",
"backToDashboard": "Torna alla dashboard"
},
"offlineBanner": {
"message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online."
},
"nav": {
"dashboard": "Dashboard",
@@ -353,7 +354,6 @@
"notesPlaceholder": "es. dopo il pasto",
"formReset": "Reimposta modulo",
"saveError": "Errore durante il salvataggio",
"loadError": "Impossibile caricare le misurazioni",
"duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.",
"deleteError": "Errore durante l’eliminazione",
"invalidValue": "Inserisci un valore valido.",
@@ -797,6 +797,7 @@
},
"charts": {
"loadingLabel": "Caricamento del grafico…",
"loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.",
"weight": "Peso",
"bloodPressure": "Pressione arteriosa",
"pulse": "Pulsazioni",
@@ -862,6 +863,8 @@
"emptyStateDescription": "Registra almeno 3 voci per sbloccare la linea di tendenza.",
"needMoreDistinctDaysTitle": "Servono più giorni distinti",
"needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.",
"noDataInRangeTitle": "Nessun dato in questo intervallo",
"noDataInRangeDescription": "Nessuna misurazione rientra nell'intervallo selezionato. Cambia intervallo o registra una nuova misurazione.",
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
"deltaUnchanged": "corrisponde alla tua media",
@@ -1070,6 +1073,7 @@
"refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute — riformula la domanda in questo perimetro.",
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
"errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.",
"tagline": "Coach personale di salute, basato sui tuoi dati.",
"newChat": "Nuova chat",
"send": "Invia",
@@ -1520,7 +1524,7 @@
"profileDescription": "Altezza, data di nascita e sesso sbloccano obiettivi personalizzati.",
"profileCta": "Apri profilo",
"measurementTitle": "Registra la prima misurazione",
"measurementDescription": "Basta una misurazione per disegnare il primo tile di trend.",
"measurementDescription": "Una misurazione attiva il tile — il trend si disegna dalla 5ª misurazione.",
"measurementCta": "Aggiungi misurazione",
"medicationTitle": "Aggiungi un farmaco",
"medicationDescription": "Monitora l’aderenza e ricevi promemoria proprio quando ti servono.",
@@ -1903,12 +1907,16 @@
"connected": "Connesso",
"errorReconnect": "Errore — riconnetti",
"warningServerError": "Connesso, errore del server",
"parkedReconnect": "In pausa — riconnettere manualmente",
"notConnected": "Non connesso",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
@@ -1938,12 +1946,21 @@
"collapse": "Collapse",
"expand": "Expand",
"dangerZone": "Delete All Data",
"dangerZoneTitle": "Danger zone",
"dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
"dangerZoneConfirm": "Delete all data?",
"dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
"dangerZoneSuccess": "All personal data has been deleted",
"dangerZoneDeleteFailed": "Deletion failed",
"finalDelete": "Delete permanently",
"deleteAccountCardTitle": "Delete account entirely",
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.",
"deleteAccountCta": "Delete account",
"deleteAccountConfirmTitle": "Delete account permanently?",
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.",
"deleteAccountSuccess": "Account deleted — signing you out.",
"deleteAccountFailed": "Account could not be deleted",
"deleteAccountFinal": "Delete permanently",
"saved": "Salvato",
"savingError": "Error saving",
"moodLogTitle": "moodLog",
+27 -10
View File
@@ -145,19 +145,20 @@
"moodSensitive": "Wrażliwe — domyślnie wyłączone.",
"compliance": "Przestrzeganie leków",
"sleep": "Czas snu",
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową."
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
"unavailableHint": "Brak danych w tym okresie"
}
},
"errorBoundary": {
"title": "Coś poszło nie tak",
"description": "Wystąpił nieoczekiwany błąd. Możesz spróbować ponownie, skopiować szczegóły techniczne lub otworzyć zgłoszenie błędu."
},
"errors": {
"notFound": {
"title": "Nie znaleziono strony",
"body": "Strona, której szukasz, nie istnieje lub została przeniesiona. Wróć do panelu, aby kontynuować od miejsca, w którym przerwałeś.",
"backToDashboard": "Powrót do panelu"
}
"notFound": {
"title": "Ta strona nie istnieje.",
"backToDashboard": "Powrót do panelu"
},
"offlineBanner": {
"message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online."
},
"nav": {
"dashboard": "Panel",
@@ -353,7 +354,6 @@
"notesPlaceholder": "np. po jedzeniu",
"formReset": "Resetuj formularz",
"saveError": "Błąd zapisu",
"loadError": "Nie udało się wczytać pomiarów",
"duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.",
"deleteError": "Błąd usuwania",
"invalidValue": "Wprowadź poprawną wartość.",
@@ -797,6 +797,7 @@
},
"charts": {
"loadingLabel": "Ładowanie wykresu…",
"loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.",
"weight": "Waga",
"bloodPressure": "Ciśnienie tętnicze",
"pulse": "Tętno",
@@ -862,6 +863,8 @@
"emptyStateDescription": "Zarejestruj co najmniej 3 wpisy, aby odblokować linię trendu.",
"needMoreDistinctDaysTitle": "Potrzeba więcej różnych dni",
"needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.",
"noDataInRangeTitle": "Brak danych w tym zakresie",
"noDataInRangeDescription": "Żadne pomiary nie mieszczą się w wybranym zakresie. Zmień zakres lub dodaj nowy pomiar.",
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
"deltaUnchanged": "odpowiada twojej średniej",
@@ -1070,6 +1073,7 @@
"refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne — przeformułuj pytanie w tych ramach.",
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
"errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.",
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
"newChat": "Nowa rozmowa",
"send": "Wyślij",
@@ -1520,7 +1524,7 @@
"profileDescription": "Wzrost, data urodzenia i płeć odblokowują spersonalizowane cele.",
"profileCta": "Otwórz profil",
"measurementTitle": "Zarejestruj pierwszy pomiar",
"measurementDescription": "Jeden pomiar wystarczy, aby narysować pierwszy kafelek trendu.",
"measurementDescription": "Jeden pomiar aktywuje kafelek — trend rysuje się od 5. pomiaru.",
"measurementCta": "Dodaj pomiar",
"medicationTitle": "Dodaj lek",
"medicationDescription": "Śledź przestrzeganie i otrzymuj przypomnienia dokładnie wtedy, kiedy ich potrzebujesz.",
@@ -1903,12 +1907,16 @@
"connected": "Połączono",
"errorReconnect": "Błąd — połącz ponownie",
"warningServerError": "Połączono, błąd serwera",
"parkedReconnect": "Wstrzymane — połącz ręcznie",
"notConnected": "Nie połączono",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
@@ -1938,12 +1946,21 @@
"collapse": "Collapse",
"expand": "Expand",
"dangerZone": "Delete All Data",
"dangerZoneTitle": "Danger zone",
"dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
"dangerZoneConfirm": "Delete all data?",
"dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
"dangerZoneSuccess": "All personal data has been deleted",
"dangerZoneDeleteFailed": "Deletion failed",
"finalDelete": "Delete permanently",
"deleteAccountCardTitle": "Delete account entirely",
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.",
"deleteAccountCta": "Delete account",
"deleteAccountConfirmTitle": "Delete account permanently?",
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.",
"deleteAccountSuccess": "Account deleted — signing you out.",
"deleteAccountFailed": "Account could not be deleted",
"deleteAccountFinal": "Delete permanently",
"saved": "Zapisano",
"savingError": "Error saving",
"moodLogTitle": "moodLog",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.42",
"version": "1.4.45",
"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",
@@ -25,6 +25,7 @@
"packageManager": "pnpm@10.31.0",
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/generate-sw-version.mjs",
"build": "next build",
"analyze": "ANALYZE=1 next build",
"bundle-report": "node scripts/print-bundle-report.mjs",
@@ -0,0 +1,46 @@
-- v1.4.43 W14 — IntegrationStatus parking + per-kind failure counters.
--
-- Two additive columns on `integration_statuses`:
--
-- 1. `consecutive_failures_by_kind` (jsonb)
-- A bucketed counter that increments only the bucket matching the
-- failure kind. The single-column `consecutive_failures` integer
-- stays in place for one release as a fallback (callers reading
-- the alert-ladder still use `Math.max(...buckets)`); it will be
-- removed in v1.4.44 once every reader is migrated.
--
-- Default shape: { "transient": 0, "reauth_required": 0,
-- "persistent": 0 }. A `null` value means the
-- row predates this migration — the status writer back-fills it
-- on the next write by bucketing the old `consecutive_failures`
-- value into the current `state`.
--
-- 2. `persistent_failure_started_at` (timestamp)
-- Wall-clock timestamp of the first persistent failure in the
-- current streak. Cleared whenever the persistent bucket resets
-- to zero. Once the gap between `now()` and this timestamp
-- exceeds 24h with the persistent bucket still non-zero, the
-- status writer flips `state` to `"parked"` — the integration
-- stops retrying and the UI surfaces a "Paused — reconnect
-- manually" pill that hits `/api/integrations/withings/resume`
-- to clear the park.
--
-- Idempotent guards (`IF NOT EXISTS`) match the 0067 / 0070 / 0071
-- pattern so reruns are safe.
--
-- The `state` column is a free-form string today (deliberate per the
-- 0029 migration comment — "adding new sentinels in v1.5 doesn't
-- require a migration"). Adding `"parked"` as a recognised value is
-- therefore a no-op at the schema level; the application layer owns
-- the enum widening.
--
-- Reversibility: down migration is `ALTER TABLE ... DROP COLUMN IF
-- EXISTS` on both new columns + a single UPDATE that clears any
-- `state = 'parked'` rows back to `'error_transient'`. Both columns
-- are nullable so existing rows keep working.
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "consecutive_failures_by_kind" JSONB;
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "persistent_failure_started_at" TIMESTAMP(3);
+13 -11
View File
@@ -1342,17 +1342,19 @@ model MedicationComplianceRollup {
// `integration` values are bare strings ("withings", "moodlog") rather
// than an enum so future integrations don't require a migration.
model IntegrationStatus {
id String @id @default(cuid())
userId String @map("user_id")
integration String // "withings" | "moodlog"
state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected"
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")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(cuid())
userId String @map("user_id")
integration String // "withings" | "moodlog"
state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected" | "parked"
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
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)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+16 -1
View File
@@ -13,7 +13,22 @@
// polls `/api/version`, compares against `NEXT_PUBLIC_APP_VERSION`,
// and triggers an SW-unregister + cache-wipe + hard reload when the
// server moves ahead of the running shell.
const CACHE_VERSION = "v1.4.38.4";
//
// v1.4.43 QoL (L3) — read the active version from `self.__APP_VERSION__`,
// which the build step writes to `/public/sw-version.js` (loaded
// synchronously below via `importScripts`). Pre-fix the literal was
// hand-bumped per release and quietly drifted four releases stale, so
// every deploy between v1.4.38.4 → v1.4.42 served the previous shell's
// cache instead of evicting it on activate. The fallback literal stays
// in case the import is missing (legacy SW versions, dev mode without
// the build step), but the source of truth is now the generated file.
try {
importScripts("/sw-version.js");
} catch {
// Fall through to the literal fallback below.
}
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) || "v1.4.43";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
@@ -0,0 +1,72 @@
/**
* v1.4.43 QoL (L3) — verify the SW build step + the SW source contract.
*
* Pre-fix `public/sw.js` hard-coded `const CACHE_VERSION = "v1.4.38.4"`
* which drifted four releases stale. The current shape:
* - `scripts/generate-sw-version.mjs` writes `self.__APP_VERSION__`
* into `public/sw-version.js` from `package.json`.
* - `public/sw.js` `importScripts('/sw-version.js')` and reads
* `self.__APP_VERSION__` (falling back to a literal).
*/
import { execFileSync } from "node:child_process";
import {
existsSync,
readFileSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { describe, it, expect, afterAll } from "vitest";
const ROOT = join(__dirname, "../..");
const SW_PATH = join(ROOT, "public/sw.js");
const GENERATED_PATH = join(ROOT, "public/sw-version.js");
const SCRIPT_PATH = join(ROOT, "scripts/generate-sw-version.mjs");
const PKG_PATH = join(ROOT, "package.json");
describe("public/sw.js CACHE_VERSION wiring", () => {
it("does not carry a hard-coded pre-v1.4.43 literal", () => {
const sw = readFileSync(SW_PATH, "utf8");
// The exact pre-fix literal — pin out a regression where someone
// re-hand-codes the version instead of going through the script.
expect(sw).not.toContain('const CACHE_VERSION = "v1.4.38.4"');
// The fix routes via `self.__APP_VERSION__`.
expect(sw).toContain("self.__APP_VERSION__");
});
it("loads the generated version file via importScripts", () => {
const sw = readFileSync(SW_PATH, "utf8");
expect(sw).toContain("importScripts");
expect(sw).toContain("/sw-version.js");
});
it("falls back to a literal when the import fails", () => {
const sw = readFileSync(SW_PATH, "utf8");
// The fallback OR-expression keeps the SW functional in dev mode
// where the build script has not run. Pin the shape so the fallback
// can never silently vanish.
expect(sw).toMatch(/self\.__APP_VERSION__\s*\)?\s*\|\|\s*"v\d/);
});
});
describe("scripts/generate-sw-version.mjs", () => {
afterAll(() => {
// Don't leave the generated artefact behind for the next test run.
if (existsSync(GENERATED_PATH)) rmSync(GENERATED_PATH);
});
it("writes self.__APP_VERSION__ matching package.json", () => {
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
expect(existsSync(GENERATED_PATH)).toBe(true);
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8"));
const generated = readFileSync(GENERATED_PATH, "utf8");
expect(generated).toContain(`self.__APP_VERSION__ = "v${pkg.version}"`);
});
it("re-running the script is idempotent (overwrites with same content)", () => {
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
const a = readFileSync(GENERATED_PATH, "utf8");
execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" });
const b = readFileSync(GENERATED_PATH, "utf8");
expect(b).toBe(a);
});
});
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* v1.4.43 QoL (L3) — write the active package.json version into
* `public/sw-version.js`. The Service Worker (`public/sw.js`) loads
* this file via `importScripts('/sw-version.js')` and reads
* `self.__APP_VERSION__` to key its `CACHE_VERSION`.
*
* Pre-fix the SW carried a hand-bumped literal that drifted four
* releases stale; the `activate`-step's old-cache eviction became
* a no-op for every deploy in v1.4.38.4 → v1.4.42. Running this
* script as a `prebuild` step re-anchors the version on every build,
* so the eviction now fires reliably.
*
* Idempotent: writes the file unconditionally. Safe to commit the
* generated file or `.gitignore` it — either way `pnpm build` reruns
* the generator.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const pkgPath = join(repoRoot, "package.json");
const outPath = join(repoRoot, "public", "sw-version.js");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const version = `v${pkg.version}`;
const banner =
"// AUTO-GENERATED by scripts/generate-sw-version.mjs — do not edit.\n";
const body = `self.__APP_VERSION__ = ${JSON.stringify(version)};\n`;
writeFileSync(outPath, banner + body, "utf8");
// One-line log so the build output shows the version we baked in.
process.stdout.write(`sw-version.js → ${version}\n`);
@@ -30,9 +30,27 @@ vi.mock("@/lib/rate-limit", () => ({
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
}),
// v1.4.43 W13 M-4 — the route now routes through the auth-surface
// wrapper. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(
(raw: string) =>
`hash-${Buffer.from(raw, "utf-8")
.toString("hex")
.slice(0, 16)
.padStart(16, "0")}`,
),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -44,7 +62,10 @@ vi.mock("next/headers", () => ({
import { POST } from "@/app/api/auth/check-user/route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
} from "@/lib/rate-limit";
interface CheckUserBody {
data: {
@@ -69,6 +90,12 @@ beforeEach(() => {
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
} as never);
});
afterEach(() => {
@@ -149,11 +176,12 @@ describe("POST /api/auth/check-user", () => {
});
it("returns 429 when the per-IP rate-limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 15 * 60 * 1000,
});
ip: "203.0.113.1",
} as never);
const res = await POST(makeRequest({ identifier: "anyone@example.com" }));
expect(res.status).toBe(429);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow slim
* fetches.
*
* Pre-fix the strip only painted once `trendCards.length > 0`. When
* both slim + thick `/api/analytics` slices lagged (cache eviction,
* cold start) the user saw the page header + zero tiles + then a
* burst of tiles 9 s later. The fix paints a layout-stable silhouette
* keyed off the user's configured tile count
* (`layout.widgets.filter(w => w.tileVisible ?? w.visible).length`)
* while `analyticsSlimQuery.isLoading` is still in flight, so the
* page reserves the strip's footprint immediately and swaps to the
* real tiles the moment slim resolves.
*
* Textual pins keep the contract honest — render-mounting the
* dashboard page would haul in TanStack-Query, auth, i18n, layout
* and analytics scaffolding for a guard a substring scan already
* proves.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
describe("v1.4.43 W11 — dashboard tile-strip skeleton", () => {
it("derives `configuredTileCount` from the resolved layout", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/,
);
});
it("gates the skeleton on slim-analytics loading + empty trend cards + configured tiles", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/const\s+showTileStripSkeleton\s*=\s*[\s\S]*?trendCards\.length\s*===\s*0[\s\S]*?analyticsSlimQuery\.isLoading[\s\S]*?configuredTileCount\s*>\s*0/,
);
});
it("renders the skeleton with the same auto-fit grid track the real strip uses", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/showTileStripSkeleton\s*&&\s*\(\s*<div[\s\S]*?\[grid-template-columns:repeat\(auto-fit,minmax\(min\(100%,11rem\),1fr\)\)\]/,
);
});
it("keys the skeleton card count off configuredTileCount", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/Array\.from\(\{\s*length:\s*configuredTileCount\s*\}\)/,
);
});
it("marks the skeleton aria-hidden + carries motion-reduce", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const skeletonBlock = src.match(
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
);
expect(skeletonBlock).not.toBeNull();
const block = skeletonBlock?.[0] ?? "";
expect(block).toContain('aria-hidden="true"');
expect(block).toContain("motion-reduce:animate-none");
});
it("suppresses the EmptyState while the skeleton is showing", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/trendCards\.length\s*===\s*0\s*&&\s*charts\.length\s*===\s*0\s*&&\s*!showTileStripSkeleton/,
);
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* v1.4.43 QoL (L1) — pin the tightened 404 / global-error copy so a
* future translator does not re-introduce the v1.4.27 marketing
* paragraph. The 404 page now ships with i18n keys (`notFound.*`)
* across all six locales; the global-error boundary remains static
* bilingual (no i18n provider available at that level).
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const LOCALES = ["en", "de", "fr", "es", "it", "pl"] as const;
interface Messages {
notFound: {
title: string;
backToDashboard: string;
};
}
function load(locale: string): Messages {
const raw = readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8");
return JSON.parse(raw) as Messages;
}
describe("notFound copy", () => {
it.each(LOCALES)("%s: ships title + backToDashboard keys", (locale) => {
const messages = load(locale);
expect(messages.notFound.title.trim().length).toBeGreaterThan(0);
expect(messages.notFound.backToDashboard.trim().length).toBeGreaterThan(0);
});
it.each(LOCALES)(
"%s: title is tight — under 60 characters, ends with a period",
(locale) => {
const messages = load(locale);
// Tightened copy is a single short statement, e.g. "Diese Seite
// existiert nicht." The 60-char ceiling pins out a future return
// of the verbose marketing block.
expect(messages.notFound.title.length).toBeLessThanOrEqual(60);
expect(messages.notFound.title).toMatch(/\.$/);
},
);
it("de: matches the tightened copy exactly", () => {
const messages = load("de");
expect(messages.notFound.title).toBe("Diese Seite existiert nicht.");
});
it("en: matches the tightened copy exactly", () => {
const messages = load("en");
expect(messages.notFound.title).toBe("This page doesn't exist.");
});
});
describe("global-error bilingual copy", () => {
// The global-error boundary cannot reach the i18n provider (root
// layout has failed). The v1.4.43 audit asked for a bilingual
// lockup so a German user does not read pure English in a bad-
// state moment. Read the file and assert both languages appear.
const globalErrorPath = join(ROOT, "src/app/global-error.tsx");
const source = readFileSync(globalErrorPath, "utf8");
it("ships the German leading phrase next to the English fallback", () => {
expect(source).toContain("Etwas ist schiefgegangen");
expect(source).toContain("Something went wrong");
});
it("ships the German error subtitle next to the English fallback", () => {
expect(source).toContain("Ein kritischer Fehler ist aufgetreten");
// The JSX-formatter wraps the English fallback across two lines, so
// collapse all whitespace before searching.
const collapsed = source.replace(/\s+/g, " ");
expect(collapsed).toContain("A critical error occurred");
});
});
@@ -0,0 +1,93 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
feedback: {
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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH } from "../route";
import { getSession } from "@/lib/auth/session";
const SESSION_ADMIN = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
};
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/admin/feedback/f1", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never);
});
describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `status` enum + adminNote too long.
const res = await PATCH(
patchReq({ status: "junk", adminNote: "x".repeat(10000) }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
// The schema has only two knobs (status + adminNote), so the strict
// 3-issue ceiling isn't reachable. Multi-issue contract is pinned at
// ≥ 2 by the case above.
const res = await PATCH(
patchReq({ status: 999, adminNote: 123 }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
+3 -1
View File
@@ -6,6 +6,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
getClientIp,
} from "@/lib/api-response";
@@ -25,7 +26,8 @@ export const PATCH = apiHandler(
const parsed = updateFeedbackSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const before = await prisma.feedback.findUnique({ where: { id } });
@@ -505,4 +505,46 @@ describe("PUT /api/admin/settings", () => {
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `registrationEnabled` (string not boolean) + bad
// `reminderLateMinutes` (string not int).
const res = await PUT(
jsonReq({
registrationEnabled: "string",
reminderLateMinutes: "string",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
const res = await PUT(
jsonReq({
registrationEnabled: "string",
reminderLateMinutes: "string",
reminderMissedMinutes: "string",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
@@ -154,4 +154,47 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
const res = await PUT(putReq({ assistantPotatoEnabled: false }));
expect(res.status).toBe(422);
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
// Two bad-typed flags.
const res = await PUT(
putReq({
assistantEnabled: "string",
assistantCoachEnabled: 999,
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
const res = await PUT(
putReq({
assistantEnabled: "string",
assistantCoachEnabled: 999,
assistantBriefingEnabled: "string",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
@@ -32,6 +32,7 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
@@ -124,7 +125,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const parsed = assistantFlagsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const updates: Partial<AssistantFlagsRow> = {};
+3 -1
View File
@@ -5,6 +5,7 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -71,7 +72,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = adminSettingsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
@@ -205,4 +205,40 @@ describe("PUT /api/admin/users/[id]", () => {
const res = await PUT(r, params("u1"));
expect(res.status).toBe(415);
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad username (too short) + bad role enum.
const res = await PUT(
jsonReq({ username: "x", role: "GOD" }),
params("u1"),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
const res = await PUT(
jsonReq({ username: "x", email: "not-an-email", role: "GOD" }),
params("u1"),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
+3 -1
View File
@@ -5,6 +5,7 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -38,7 +39,8 @@ export const PUT = apiHandler(
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const target = await prisma.user.findUnique({ where: { id } });
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the
// shape only matters here, so a deterministic stand-in keeps the test
// stable without standing up the env-keyed CipherSuite. The PII check
// below asserts the raw identifier never appears in the audit payload.
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(
(raw: string) =>
`hash-${Buffer.from(raw, "utf-8")
.toString("hex")
.slice(0, 16)
.padStart(16, "0")}`,
),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }),
// v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface
// wrapper so the trust-violation branch can swap to a tighter global
// bucket. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 30,
resetAt: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
function expectedHash(identifier: string): string {
return (hashToken as unknown as (s: string) => string)(identifier);
}
function makeRequest(body: { identifier: string }): NextRequest {
return new NextRequest("http://localhost/api/auth/check-user", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.mocked(prisma.user.findFirst).mockReset();
vi.mocked(auditLog).mockReset();
vi.mocked(auditLog).mockResolvedValue(undefined as never);
});
describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
const res = await POST(makeRequest({ identifier: "ghost@example.com" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[0]).toBe("auth.check-user");
expect(args[1]).toEqual({
ipAddress: "203.0.113.1",
details: {
branch: "not_found",
identifier_hash: expectedHash("ghost@example.com"),
},
});
// The raw identifier must not leak into the details — every other
// /api/auth/* audit row already hashes; the check-user route follows
// the same shape.
const details = args[1]?.details as Record<string, unknown> | undefined;
expect(details).not.toHaveProperty("identifier");
expect(JSON.stringify(details)).not.toContain("ghost@example.com");
});
it("writes an audit row for the passkey_only branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-1",
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({
userId: "user-1",
ipAddress: "203.0.113.1",
details: {
branch: "passkey_only",
identifier_hash: expectedHash("marc"),
},
});
});
it("writes an audit row for the email_fallback branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-2",
passwordHash: "$argon2id$...",
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "marc@example.com" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "email_fallback",
identifier_hash: expectedHash("marc@example.com"),
});
expect(args[1]?.userId).toBe("user-2");
});
it("writes an audit row for the exists branch (no passkey + no password)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-3",
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "stub" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "exists",
identifier_hash: expectedHash("stub"),
});
expect(args[1]?.userId).toBe("user-3");
});
});
+30 -5
View File
@@ -37,10 +37,15 @@
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
@@ -57,12 +62,14 @@ export type CheckUserBranch =
| "exists";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(
`auth:check-user:${ip}`,
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:check-user",
30,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Too many requests. Please try again later." },
@@ -92,8 +99,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin
// can later answer "did anyone enumerate accounts last week?". The
// wide-event `annotate()` log is sampled and short-retention; the
// audit ledger is durable. We hash the identifier (same shape as the
// `auth.login.failed` row) so the literal email/username never lands
// in `AuditLog.details` — the v1.4.20 retroactive PII directive bars
// raw user-typed strings from operator-readable artefacts.
const identifierHash = hashToken(identifier);
if (!user) {
annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } });
await auditLog("auth.check-user", {
ipAddress: ip,
details: { branch: "not_found", identifier_hash: identifierHash },
});
return apiSuccess({
branch: "not_found" satisfies CheckUserBranch,
hasPasskey: false,
@@ -110,5 +130,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
else branch = "exists";
annotate({ action: { name: "auth.check-user" }, meta: { branch } });
await auditLog("auth.check-user", {
userId: user.id,
ipAddress: ip,
details: { branch, identifier_hash: identifierHash },
});
return apiSuccess({ branch, hasPasskey, hasPassword });
});
@@ -28,6 +28,15 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
// v1.4.43 W13 M-4 — auth surfaces now route through the wrapper that
// returns `{ip, ...rate-limit-result}`. Default to a clean per-IP
// result so the existing tests don't need to set every IP up.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
@@ -55,7 +64,11 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
@@ -94,6 +107,12 @@ beforeEach(() => {
remaining: 5,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
+17 -1
View File
@@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
ip: "1.2.3.4",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
@@ -61,7 +67,11 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
const TYPED_EMAIL = "leaked-identifier@example.com";
@@ -84,6 +94,12 @@ beforeEach(() => {
remaining: 5,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
});
+11 -4
View File
@@ -6,10 +6,9 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -22,8 +21,16 @@ import {
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`auth:login:${ip}`, 5, 15 * 60 * 1000);
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
// global bucket when the trust chain is misconfigured; otherwise it
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:login",
5,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{
@@ -1,14 +1,18 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { apiSuccess } from "@/lib/api-response";
import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: Request) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(
`auth:passkey-login-options:${ip}`,
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:passkey-login-options",
10,
15 * 60 * 1000,
);
@@ -24,6 +24,9 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
// v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface
// wrapper. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
@@ -50,7 +53,10 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { checkRateLimit } from "@/lib/rate-limit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
} from "@/lib/rate-limit";
const FAKE_USER = {
id: "user-1",
@@ -81,6 +87,12 @@ beforeEach(() => {
remaining: 9,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: "user-1" },
@@ -4,12 +4,11 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -23,12 +22,14 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
const ip = getClientIp(request);
const rl = await checkRateLimit(
`auth:passkey-verify:${ip}`,
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:passkey-verify",
10,
15 * 60 * 1000,
);
const ip = rl.ip;
if (!rl.allowed) {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
@@ -0,0 +1,116 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/password.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
destroyAllSessions: vi.fn().mockResolvedValue(undefined),
createSession: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn().mockResolvedValue(true),
hashPassword: vi.fn().mockResolvedValue("hashed"),
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn().mockResolvedValue("en"),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
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: "tester",
email: "t@example.com",
role: "USER" as const,
passwordHash: "h",
onboardingCompletedAt: new Date(),
locale: "en",
},
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Empty currentPassword + tooShort newPassword.
const res = await POST(
postReq({ currentPassword: "", newPassword: "x" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Both empty + extra invalid type.
const res = await POST(
postReq({ currentPassword: "", newPassword: 123 }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
// currentPassword empty (1) + newPassword wrong type (1) — might
// collapse to 2 issues. We pin the contract on ≥ 2.
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
+3 -1
View File
@@ -11,6 +11,7 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -36,7 +37,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { currentPassword, newPassword } = parsed.data;
+9 -4
View File
@@ -7,12 +7,11 @@ import { apiHandler } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { resolveTokenPolicy } from "@/lib/auth/native-client";
import {
rotateRefreshToken,
@@ -20,8 +19,14 @@ import {
} from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000);
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:refresh",
60,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return apiError("Too many refresh attempts. Please retry later.", 429);
}
@@ -0,0 +1,118 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
count: vi.fn().mockResolvedValue(1),
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn(),
},
appSettings: {
findUnique: vi.fn().mockResolvedValue({ registrationEnabled: true }),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/password", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed"),
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn().mockResolvedValue("en"),
}));
vi.mock("@/lib/tz/resolver", () => ({
isValidTimezone: vi.fn().mockReturnValue(true),
resolveServerDefaultTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "1.2.3.4",
} as never);
});
describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad email + too-short username.
const res = await postPathThrough({ email: "not-an-email", username: "x" });
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Bad email + bad username + bad password.
const res = await postPathThrough({
email: "not-an-email",
username: "x",
password: "weak",
});
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
async function postPathThrough(body: unknown): Promise<Response> {
return POST(postReq(body));
}
+13 -5
View File
@@ -6,10 +6,10 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -21,8 +21,15 @@ import {
} from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`auth:register:${ip}`, 5, 15 * 60 * 1000);
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:register",
5,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{
@@ -60,7 +67,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { email, username, password, timezone: timezoneInput } = parsed.data;
@@ -0,0 +1,101 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => s.replace(/^enc-/, ""),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { 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: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/bugreport", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
githubIssueTokenEncrypted: "enc-token",
githubIssueRepo: "owner/repo",
bugReportEnabled: true,
} as never);
});
describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// description too short + screenshot doesn't match data-URL regex.
const res = await POST(
postReq({ description: "tiny", screenshot: "not-a-data-url" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
// Schema has only two knobs (description, screenshot). Pin contract
// on ≥ 2.
const res = await POST(
postReq({ description: 123, screenshot: 999 }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
+3 -1
View File
@@ -4,6 +4,7 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -83,7 +84,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = bugReportSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { description, screenshot } = parsed.data;
@@ -167,4 +167,44 @@ describe("POST /api/consent/ai", () => {
},
});
});
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Bad kind enum + empty artefact.
const res = await POST(
req({
kind: "junk",
artefact: "",
signedAt: "2026-05-18T10:00:00.000Z",
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({ kind: "junk", artefact: "", signedAt: "not-iso" }),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
@@ -390,4 +390,48 @@ describe("append-only invariant", () => {
};
expect(body.data.receipt?.id).toBe("rcpt-2");
});
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
it("GET surfaces multi-issue validation errors (≥ 1)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=junk",
),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
// The latest query only has a single `kind` knob, so the strict
// 2-issue case can't be reached. The shared helper itself is
// covered exhaustively by api-response-zod.test.ts; this case
// pins the route's new envelope on ≥ 1 issue.
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("DELETE surfaces multi-issue validation errors (≥ 1)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=junk",
{ method: "DELETE" },
),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
});
});
});
+8 -3
View File
@@ -16,7 +16,10 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -38,7 +41,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 400);
// v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422).
return returnAllZodIssues(parsed.error, 400);
}
const { kind } = parsed.data;
@@ -86,7 +90,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 400);
// v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422).
return returnAllZodIssues(parsed.error, 400);
}
const { kind } = parsed.data;
+7 -2
View File
@@ -9,7 +9,11 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { consentPostBody } from "@/lib/validations/consent";
@@ -23,7 +27,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = consentPostBody.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 400);
// v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422).
return returnAllZodIssues(parsed.error, 400);
}
const { kind, artefact, signedAt } = parsed.data;
@@ -0,0 +1,148 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT
* /api/dashboard/chart-overlay-prefs.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardWidgets: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/dashboard/chart-overlay-prefs", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
__resetAuditDedupMemoForTests();
});
describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `chartKey` enum + `prefs.showTrendIndicator` not a boolean.
const res = await PUT(
putReq({
chartKey: "not-a-known-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Bad chartKey + bad showTrendIndicator + bad comparisonBaseline.
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "tomorrow",
},
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a dashboard.chart-overlay.validation-failed audit row", async () => {
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("dashboard.chart-overlay.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
});
});
@@ -16,7 +16,13 @@
* same request.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
@@ -49,7 +55,34 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const parsed = prefsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — sibling of `/api/dashboard/widgets`; the per-chart
// overlay popover hits this on every toggle so the multi-issue
// envelope matches widgets exactly. Audit breadcrumb keyed
// `dashboard.chart-overlay.validation-failed`, deduped via the
// shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a
// misbehaving iOS client looping the popover cannot flood the
// audit ledger.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "dashboard.chart-overlay.validation-failed" },
meta: { issue_count: issues.length },
});
if (
shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed")
) {
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.chart-overlay.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
}
return returnAllZodIssues(parsed.error, 422);
}
// Read-modify-write inside a Serializable transaction so two
+5 -33
View File
@@ -42,39 +42,11 @@ import type { NextRequest } from "next/server";
// the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift.
const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS);
// v1.4.43 B2 — in-process dedup memo for the validation-failed audit row.
// A misbehaving iOS client looping on a 422 can otherwise write thousands
// of identical audit rows per minute. We dedup on `(userId, action)` with
// a 60 s window: only the first 422 inside the window writes; subsequent
// 422s still return the full multi-issue envelope but skip the breadcrumb.
// In-memory only — a process restart resets the map, which is the safe
// failure mode (worst case: one extra audit row per restart). Cluster
// deployments still get one row per process per minute, which is fine for
// the operator-grep use case the audit row exists for.
const AUDIT_DEDUP_WINDOW_MS = 60_000;
const auditDedupMemo = new Map<string, number>();
function shouldEmitAuditRow(userId: string, action: string, now: number): boolean {
const key = `${userId}:${action}`;
const last = auditDedupMemo.get(key);
if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) {
return false;
}
auditDedupMemo.set(key, now);
// Opportunistic GC — drop entries older than the window on every miss so
// the map cannot grow unbounded across a long-running process.
if (auditDedupMemo.size > 512) {
for (const [k, t] of auditDedupMemo) {
if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k);
}
}
return true;
}
/** @internal — exported for the dedup unit test only. */
export function __resetAuditDedupMemoForTests(): void {
auditDedupMemo.clear();
}
// v1.4.43 B2 — dedup helper extracted to `src/lib/audit-dedup.ts` so both
// `/api/dashboard/widgets` and `/api/dashboard/chart-overlay-prefs` share
// the same 60 s `(userId, action)` window.
export { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
const layoutSchema = z.object({
version: z.literal(1),
@@ -12,6 +12,9 @@ vi.mock("@/lib/db", () => ({
notificationChannel: {
upsert: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
},
}));
@@ -67,6 +70,7 @@ beforeEach(() => {
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
id: "ch-1",
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("POST /api/devices", () => {
@@ -231,3 +235,74 @@ describe("POST /api/devices", () => {
expect(res.status).toBe(201);
});
});
describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Missing `token` (required) + invalid `apnsEnvironment` enum.
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Missing `token` + invalid `apnsToken` (non-hex) + invalid `bundleId` (number, not string).
const res = await POST(
req({
bundleId: 42,
apnsToken: "not-hex-z",
apnsEnvironment: "sandbox",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes one audit-ledger row keyed devices.register.validation-failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("devices.register.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
+27 -2
View File
@@ -24,7 +24,13 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -71,7 +77,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = deviceSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS device-registration multi-issue 422 + audit
// breadcrumb. APNs-token + bundleId + environment + model + locale
// can all fail simultaneously on a misconfigured client build.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "devices.register.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const {
@@ -103,3 +103,53 @@ describe("POST /api/feedback bug-report toggle gate", () => {
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
bugReportEnabled: true,
} as never);
// subject too short + description too short.
const res = await POST(
jsonRequest({
category: "BUG",
subject: "x",
description: "short",
}) as never,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
bugReportEnabled: true,
} as never);
// bad subject + bad description + bad screenshot.
const res = await POST(
jsonRequest({
category: "BUG",
subject: "x",
description: "short",
screenshot: "not-a-data-url",
}) as never,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
+3 -1
View File
@@ -9,6 +9,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
getClientIp,
} from "@/lib/api-response";
@@ -45,7 +46,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = createFeedbackSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const feedback = await prisma.feedback.create({
@@ -0,0 +1,147 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/ingest/medication.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
medicationIntakeEvent: {
findFirst: vi.fn(),
create: vi.fn(),
},
medication: {
findFirst: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/ingest/medication", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer hlk_test_token",
},
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// externalIntakeSchema requires medicationName (min 1, max 200) and
// idempotencyKey (string, max 128); takenAt iso optional. Sending
// `medicationName=""` (min-1 violation) + `takenAt="not-iso"`
// forces two issues. idempotencyKey is omitted so we also catch
// the required-field check.
const res = await POST(
postReq({ medicationName: "", takenAt: "not-iso" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// medicationName not a string (type-mismatch) + idempotencyKey too
// long (>128 chars) + takenAt bad iso → 3 distinct issues.
const res = await POST(
postReq({
medicationName: 123,
idempotencyKey: "x".repeat(200),
takenAt: "not-iso",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed ingest.medication.validation-failed", async () => {
const res = await POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("ingest.medication.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
});
});
+23 -1
View File
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -92,7 +94,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = externalIntakeSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — external native ingest is a bulk-source; surface
// every Zod issue so a Telegram-bridge or HomeAssistant
// integration sees the full validation shape at once. Audit
// breadcrumb keyed `ingest.medication.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "ingest.medication.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { medicationName, takenAt, idempotencyKey } = parsed.data;
+41
View File
@@ -31,6 +31,7 @@ import { NextResponse, type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -194,6 +195,46 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
role: m.role,
content: m.content,
}));
// v1.4.43 W13 M-3 — replay-injection guard. `detectRefusal` runs
// only on the inbound `message` per turn, so an injection that
// slipped past the regex bank on a previous turn would re-enter
// the prompt every reply. Re-run the detector against every
// user-turn re-loaded from DB; on a hit, short-circuit the SSE
// with a refusal AND drop an `insights.coach.replay_injection`
// row so the failure case is observable. The audit row carries
// the conversation id (server-owned), the turn index (no PII)
// and the matched reason — never the message content. v1.4.43
// W10 simplifier-L-2 — action name follows the `<surface>.<verb>`
// convention (no `audit.` prefix; no `.replay-injection` dash).
for (let i = 0; i < priorTurns.length; i++) {
const turn = priorTurns[i];
if (turn.role !== "user") continue;
const replayed = detectRefusal({ message: turn.content, locale });
if (!replayed.refuse) continue;
annotate({
action: { name: "insights.coach.replay_injection" },
meta: { reason: replayed.reason, turnIndex: i },
});
await auditLog("insights.coach.replay_injection", {
userId,
details: {
conversationId: existing.id,
turnIndex: i,
reason: replayed.reason,
},
});
return streamRefusal({
userId,
conversationId: existing.id,
message,
refusalText:
replayed.message ??
(locale === "de"
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
});
}
} else {
const created = await createConversation({ userId, title: message });
workingConversationId = created.id;
@@ -4,6 +4,8 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
},
toJson: <T,>(v: T) => v,
}));
@@ -45,6 +47,7 @@ beforeEach(() => {
healthKitLastSyncedAt: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -116,3 +119,83 @@ describe("PATCH /api/integrations/healthkit", () => {
expect(ids).not.toContain("totallyUnknown");
});
});
describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", () => {
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/integrations/healthkit", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Two bad entries: first with bad direction, second with empty id.
const res = await PATCH(
patchReq({
entries: [
{ id: "bodyMass", direction: "JUNK" },
{ id: "", direction: "readOnly" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(
patchReq({
entries: [
{ id: "bodyMass", direction: "JUNK1" },
{ id: "", direction: "JUNK2" },
{ id: "weight", direction: "JUNK3" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed integrations.healthkit.validation-failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("integrations.healthkit.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
expect(res.status).toBe(422);
});
});
+26 -2
View File
@@ -11,7 +11,12 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -139,7 +144,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS HealthKit ingest is a hot path. Multi-issue
// 422 + audit breadcrumb so the iOS Sync engine sees the full
// diff and stops iterating one field at a time.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "integrations.healthkit.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const row = await prisma.user.findUnique({
@@ -0,0 +1,109 @@
/**
* v1.4.43 W14 — POST /api/integrations/withings/resume
*
* Three pins:
* 1. Happy path: a parked row resumes, response carries
* `wasParked: true`.
* 2. Idempotent: a connected row resumes silently, response carries
* `wasParked: false`.
* 3. Rate-limit: a 6th call within the window returns 429 with the
* `rate_limited_self` error code.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
resumeIntegrationFromPark: vi.fn(),
}));
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
interface ApiEnvelope<T> {
data: T | null;
error: string | null;
}
function emptyRequest(): Request {
return new Request("http://localhost/api/integrations/withings/resume", {
method: "POST",
headers: { "content-length": "0" },
});
}
beforeEach(() => {
vi.mocked(resumeIntegrationFromPark).mockReset();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
});
describe("POST /api/integrations/withings/resume", () => {
it("clears a parked integration and reports wasParked: true", async () => {
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
wasParked: true,
});
const response = await POST();
expect(response.status).toBe(200);
const body = (await response.json()) as ApiEnvelope<{
resumed: boolean;
wasParked: boolean;
}>;
expect(body.data).toEqual({ resumed: true, wasParked: true });
expect(resumeIntegrationFromPark).toHaveBeenCalledWith("u-1", "withings");
});
it("returns wasParked: false when the row was already connected", async () => {
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
wasParked: false,
});
const response = await POST();
expect(response.status).toBe(200);
const body = (await response.json()) as ApiEnvelope<{
resumed: boolean;
wasParked: boolean;
}>;
expect(body.data).toEqual({ resumed: true, wasParked: false });
});
it("returns 429 with rate_limited_self when the rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST();
expect(response.status).toBe(429);
const body = (await response.json()) as ApiEnvelope<unknown> & {
meta?: { errorCode?: string };
};
expect(body.error).toBeTruthy();
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(resumeIntegrationFromPark).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
/**
* v1.4.43 W14 — manual resume endpoint for a parked Withings
* integration.
*
* Background: B4 introduced the `parked` state in
* `src/lib/integrations/status.ts`. Once a Withings persistent-failure
* streak (contract-mismatch 293/294) has been running for more than
* 24h, the status writer flips the row to `parked` and the sync
* entry-point short-circuits via `isReauthRequired`. The user (or an
* operator) calls this endpoint to clear the park and let the next
* cron tick attempt the sync again.
*
* Rate-limited (5/min per user) to match the existing
* `/api/integrations/withings/test` endpoint — there's no upstream
* call here, but a misbehaving client tab loop would still rack up
* audit-log rows if the limit was absent.
*
* Returns 200 with `{ resumed: true, wasParked: boolean }`. The
* `wasParked` flag lets the caller decide whether to surface a "you
* resumed it" toast or treat the action as a no-op (the row was
* already connected / disconnected). Either way the endpoint is
* idempotent — a second click in the same second sees the same
* post-state and writes no extra audit row.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.resume" } });
const rl = await checkRateLimit(
`withings-resume:${user.id}`,
5,
60_000,
);
if (!rl.allowed) {
return apiError("Too many resume requests", 429, {
errorCode: "rate_limited_self",
});
}
const result = await resumeIntegrationFromPark(user.id, "withings");
annotate({
meta: {
withings_resume_was_parked: result.wasParked,
},
});
return apiSuccess({ resumed: true, wasParked: result.wasParked });
});
@@ -0,0 +1,134 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/measurements/[id].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findUnique: vi.fn(),
update: vi.fn(),
},
auditLog: { create: 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/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/m1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
} as never);
});
describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// `value=-1` (below min 0) + `measuredAt` not a valid ISO.
const res = await PUT(
putReq({ value: -1, measuredAt: "not-iso" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// `value` not a number + `measuredAt` not iso + `notes` too long.
const res = await PUT(
putReq({
value: "string",
measuredAt: "not-iso",
notes: "a".repeat(200),
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.update.validation-failed audit row", async () => {
const res = await PUT(
putReq({ value: -1, measuredAt: "junk" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.update.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({ value: -1, measuredAt: "junk" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
+21 -1
View File
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
@@ -61,7 +63,25 @@ export const PUT = apiHandler(
if (jsonError) return jsonError;
const parsed = updateMeasurementSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — measurement edit hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.update.validation-failed" },
meta: { issue_count: issues.length, measurement_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.update.validation-failed",
details: JSON.stringify({ issues, measurementId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
@@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({
measurementRollup: {
findMany: vi.fn(),
},
// v1.4.43 W6 — validation-failed paths write an audit breadcrumb.
auditLog: { create: vi.fn().mockResolvedValue({}) },
$queryRaw: vi.fn(),
},
}));
@@ -69,6 +71,10 @@ function getRequest(query: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// v1.4.43 W6 — validation-failed paths fire a best-effort audit-row
// write; the route swallows rejections so the test only needs a
// resolved mock to keep the catch-block silent.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
@@ -0,0 +1,253 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on the measurements route.
*
* GET, batch POST and single POST now return every Zod issue under
* `details.issues` and write an audit-ledger breadcrumb so the
* operator can grep `/api/admin/audit` for the corresponding
* `<route>.validation-failed` rows.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
measurementRollup: {
findMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
$queryRaw: vi.fn(),
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
timezone: "Europe/Berlin",
},
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
function getReq(qs: string): NextRequest {
return new NextRequest(`http://localhost/api/measurements?${qs}`);
}
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// `limit=999999` (above 5000 max) + `offset=-1` (below 0 min).
const res = await GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// bad sortBy + bad sortDir + bad aggregate.
const res = await GET(getReq("sortBy=junk&sortDir=upside&aggregate=hourly"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.list.validation-failed audit row", async () => {
const res = await GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.list.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
});
});
describe("POST /api/measurements — single — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Missing `type` (required enum) + missing `value` (required number).
const res = await POST(
postReq({ measuredAt: "not-an-iso-string" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Bad enum (`type`) + bad number (`value`) + bad iso (`measuredAt`).
const res = await POST(
postReq({ type: "NOT_A_TYPE", value: "stringy", measuredAt: "x" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.create.validation-failed audit row", async () => {
const res = await POST(postReq({ measuredAt: "junk" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.create.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ measuredAt: "junk" }));
expect(res.status).toBe(422);
});
});
describe("POST /api/measurements — batch — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors across the batch", async () => {
// Two malformed entries: one with bad type, one with bad measuredAt.
const res = await POST(
postReq([
{ type: "NOT_A_TYPE", value: 100, measuredAt: "2026-01-01T00:00:00Z" },
{ type: "WEIGHT", value: 70, measuredAt: "definitely-not-iso" },
]),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("surfaces THREE simultaneous validation errors across the batch", async () => {
const res = await POST(
postReq([
{ type: "WEIGHT", value: "string", measuredAt: "2026-01-01T00:00:00Z" },
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
{ type: "WEIGHT", value: 70, measuredAt: "nope" },
]),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.create.batch.validation-failed audit row", async () => {
const res = await POST(
postReq([
{ type: "WEIGHT", value: 70, measuredAt: "junk" },
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
]),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.create.batch.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
postReq([
{ type: "WEIGHT", value: 70, measuredAt: "junk" },
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
]),
);
expect(res.status).toBe(422);
});
});
+63 -3
View File
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
createMeasurementSchema,
@@ -45,7 +47,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMeasurementsSchema.safeParse(params);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — surface every Zod issue under `details.issues` so a
// multi-field iOS query (chart loader bug, drill-down regression)
// doesn't iterate one error per round-trip. Best-effort audit-
// ledger breadcrumb keyed `measurements.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.list.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const {
@@ -553,7 +575,26 @@ async function postMeasurement(request: NextRequest) {
measurements: body,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS batch ingest (BP + Pulse, combined BG) needs
// every issue echoed back so a one-shot retry can fix the full
// payload rather than walk it field-by-field.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.create.batch.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.batch.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const results = await prisma.$transaction(
@@ -622,7 +663,26 @@ async function postMeasurement(request: NextRequest) {
// Single mode (existing behavior)
const parsed = createMeasurementSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — single-row POST is the iOS fallback path; one
// round-trip per stale-field bug was the v1.4.41 iOS-contract
// pain point. Multi-issue envelope + audit breadcrumb.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.create.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
@@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
prisma: {
measurement: { findMany: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -38,6 +41,7 @@ function req(query: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/measurements/series", () => {
@@ -103,3 +107,62 @@ describe("GET /api/measurements/series", () => {
expect(body.data.points[0].secondary).toBeNull();
});
});
describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// bad `kind` (invalid enum) + `days=0` (below min 1).
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces multiple simultaneous validation errors (≥2)", async () => {
// The series schema has only two knobs (kind + days), so a strict
// 3-issue case is not natural. We pin the multi-issue contract on
// ≥ 2 here — the helper's 3-issue path is exhaustively covered by
// `src/lib/__tests__/api-response-zod.test.ts` and the routes with
// wider schemas (measurements, devices, mood-entries).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=junk&days=-999"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("writes a measurements.series.validation-failed audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.series.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
});
});
+24 -2
View File
@@ -12,7 +12,11 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -70,7 +74,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.series.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.series.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { kind, days } = parsed.data;
@@ -0,0 +1,102 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/medications/[id].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/medication-category", () => ({
deleteMedicationCategory: vi.fn().mockResolvedValue(undefined),
getMedicationCategories: vi.fn().mockResolvedValue({}),
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/m1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
} as never);
});
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `name` (number) + bad `active` (string).
const res = await PUT(putReq({ name: 123, active: "string" }), ROUTE_CTX);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Bad name + bad active + bad category.
const res = await PUT(
putReq({ name: 123, active: "string", category: 999 }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -166,3 +166,39 @@ describe("GET /api/medications/[id]/cadence", () => {
expect(json.data.next).toBeNull();
});
});
describe("GET /api/medications/[id]/cadence — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
} as never);
// Schema accepts `days` (coerce.number, range 1-365). Force two
// failures: non-numeric `days` + extra unknown knob is silently
// stripped, so we use the bounds-violation route: a negative number
// string still coerces to a negative int → min violation.
// For ≥2 we use a non-coercible string AND query for too-many keys.
// Cadence schema is small, so we pin the contract on ≥ 2 issues.
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=notanumber",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
});
@@ -20,7 +20,11 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import {
buildCadenceTimeline,
computeNextDose,
@@ -57,7 +61,8 @@ export const GET = apiHandler(
days: url.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const windowDays = parsed.data.days ?? 30;
const asOf = new Date();
@@ -335,3 +335,55 @@ describe("POST /api/medications/[id]/glp1", () => {
);
});
});
describe("POST /api/medications/[id]/glp1 — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "not-iso",
doseValue: "string",
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "not-iso",
doseValue: "string",
doseUnit: 999,
note: 123,
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
+3 -1
View File
@@ -22,6 +22,7 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -145,7 +146,8 @@ export const POST = apiHandler(
const parsed = glp1PostBodySchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const ip = getClientIp(request);
@@ -0,0 +1,141 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT
* /api/medications/[id]/intake/[eventId].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medicationIntakeEvent: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/m1/intake/e1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = {
params: Promise.resolve({ id: "m1", eventId: "e1" }),
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: new Date(),
} as never);
});
describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `takenAt` iso + bad `skipped` (not boolean).
const res = await PUT(
putReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Bad takenAt + bad skipped + bad scheduledFor.
const res = await PUT(
putReq({
takenAt: "not-iso",
skipped: "string",
scheduledFor: "also-not-iso",
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed medications.intake.event.update.validation-failed", async () => {
const res = await PUT(
putReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
"medications.intake.event.update.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
@@ -34,7 +36,34 @@ export const PUT = apiHandler(
if (jsonError) return jsonError;
const parsed = updateIntakeEventSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — per-event update hot path; multi-issue 422 +
// audit breadcrumb keyed
// `medications.intake.event.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.event.update.validation-failed" },
meta: {
issue_count: issues.length,
medication_id: id,
event_id: eventId,
},
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.event.update.validation-failed",
details: JSON.stringify({
issues,
medicationId: id,
eventId,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
@@ -27,11 +27,38 @@ vi.mock("@/lib/db", () => ({
medication: { findUnique: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/medications/inventory/service", () => ({
consumeOneDose: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -53,7 +80,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { GET } from "../route";
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -80,6 +107,8 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
// v1.4.43 W6 — default the audit-row write to resolve.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/medications/[id]/intake — status filter", () => {
@@ -155,3 +184,85 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
});
});
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("GET surfaces TWO simultaneous validation errors", async () => {
const res = await GET(makeRequest("status=junk&sortBy=garbage"), ROUTE_PARAMS);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces TWO simultaneous validation errors", async () => {
// Schema requires takenAt iso + skipped boolean.
const res = await POST(
postReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_PARAMS,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces THREE simultaneous validation errors", async () => {
const res = await POST(
postReq({
takenAt: "not-iso",
skipped: "string",
scheduledFor: "also-not-iso",
}),
ROUTE_PARAMS,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes audit-ledger rows for both GET and POST validation failures", async () => {
await GET(makeRequest("status=junk"), ROUTE_PARAMS);
await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalled();
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map((c) => (c[0] as { data: { action: string } }).data.action);
expect(actions).toContain("medications.intake.list.validation-failed");
expect(actions).toContain("medications.intake.create.validation-failed");
});
it("POST does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,151 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST
* /api/medications/[id]/intake/import. Preserves the
* `medication.intake.import.invalid_format` errorCode meta so the CSV
* import client UI can branch on the prefix semantics.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
},
medicationIntakeEvent: {
findUnique: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/m1/intake/import", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
} as never);
});
describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Two entries with malformed datum + malformed uhrzeit.
const res = await POST(
postReq([
{ datum: "not-a-date", uhrzeit: "07:00:00" },
{ datum: "2026-01-01", uhrzeit: "not-a-time" },
]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
// Special: the CSV-import UI branches on this errorCode to preserve
// the historical "Invalid format: …" client behaviour.
expect(body.meta?.errorCode).toBe(
"medication.intake.import.invalid_format",
);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
const res = await POST(
postReq([
{ datum: "not-a-date", uhrzeit: "not-a-time" },
{ datum: "also-bad", uhrzeit: "07:00:00" },
{ datum: "2026-01-01", uhrzeit: "weird" },
]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
meta?: { errorCode?: string };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
expect(body.meta?.errorCode).toBe(
"medication.intake.import.invalid_format",
);
});
it("writes the audit-ledger row keyed medications.intake.import.validation-failed", async () => {
const res = await POST(
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
"medications.intake.import.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
recomputeMedicationComplianceForDay,
@@ -48,7 +50,32 @@ export const POST = apiHandler(
const parsed = importSchema.safeParse(payload);
if (!parsed.success) {
return apiError(`Invalid format: ${parsed.error.issues[0].message}`, 422);
// v1.4.43 W6 — CSV import; preserve the `Invalid format:` prefix
// semantics for the existing client-side branch by setting the
// `errorCode: "medication.intake.import.invalid_format"` meta.
// The client UI can now branch on `meta.errorCode` while every
// Zod issue is also surfaced under `details.issues`. Bulk-import
// path → audit breadcrumb keyed
// `medications.intake.import.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.import.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.import.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medication.intake.import.invalid_format",
});
}
const entries = parsed.data;
+40 -3
View File
@@ -4,9 +4,10 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
intakeSchema,
@@ -41,7 +42,25 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
medicationId: id,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — per-med intake POST hot path; multi-issue 422 +
// audit breadcrumb keyed `medications.intake.create.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.create.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.create.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
@@ -185,7 +204,25 @@ export const GET = apiHandler(
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listIntakeEventsSchema.safeParse(searchParams);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422 + audit breadcrumb keyed
// `medications.intake.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { limit, offset, sortBy, sortDir, status } = parsed.data;
@@ -0,0 +1,126 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PATCH
* /api/medications/[id]/inventory/[itemId].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/inventory/service", () => ({
computeExpiresAt: vi.fn().mockReturnValue(null),
buildPatchInventoryUpdate: vi.fn().mockReturnValue({}),
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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: "tester", role: "USER" as const },
};
function patchReq(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/medications/m1/inventory/i1",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
const ROUTE_CTX = {
params: Promise.resolve({ id: "m1", itemId: "i1" }),
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "ACTIVE",
dosesRemaining: 4,
printedExpiry: null,
} as never);
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `markAsFirstUseAt` iso + bad `markAsUsedUp` (not boolean).
const res = await PATCH(
patchReq({ markAsFirstUseAt: "not-iso", markAsUsedUp: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
const res = await PATCH(
patchReq({
markAsFirstUseAt: "not-iso",
markAsUsedUp: "string",
printedExpiry: "also-bad",
notes: 123,
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -23,6 +23,7 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { updateInventoryItemSchema } from "@/lib/validations/medication";
@@ -60,7 +61,8 @@ export const PATCH = apiHandler(
const parsed = updateInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
@@ -349,3 +349,50 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
});
});
});
describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: "string",
printedExpiry: "not-iso",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: "string",
printedExpiry: "not-iso",
purchasedAt: "also-not-iso",
notes: 123,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -23,6 +23,7 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -83,7 +84,8 @@ export const POST = apiHandler(
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data;
+3 -1
View File
@@ -6,6 +6,7 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { updateMedicationSchema } from "@/lib/validations/medication";
@@ -69,7 +70,8 @@ export const PUT = apiHandler(
if (jsonError) return jsonError;
const parsed = updateMedicationSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const {
@@ -350,3 +350,70 @@ describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
});
});
});
describe("/api/medications/[id]/side-effects — 422 multi-issue (v1.4.43 W6)", () => {
it("GET surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// Bad `from` iso + bad `limit` (out of bounds).
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects?from=not-iso&limit=99999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// Missing `entry` + bad `severity`.
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
severity: "junk",
notes: "x".repeat(2000),
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("POST surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
entry: 999,
severity: "junk",
occurredAt: "not-iso",
notes: "x".repeat(5000),
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -26,6 +26,7 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -56,7 +57,8 @@ export const GET = apiHandler(
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { from, to, limit } = parsed.data;
@@ -114,7 +116,8 @@ export const POST = apiHandler(
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { entry, severity, occurredAt, notes } = parsed.data;
@@ -0,0 +1,108 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
medicationCategoryAssignment: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/cache/server-cache", () => ({
cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
f(),
),
caches: { medicationsList: {} },
__resetAllCachesForTests: vi.fn(),
}));
vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn().mockResolvedValue({}),
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/timezone", () => ({
getUserTodayBounds: vi
.fn()
.mockReturnValue({ start: new Date(), end: new Date() }),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("POST /api/medications — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Missing `name` + bad `dose` (e.g. number).
const res = await POST(postReq({ dose: 42, dosesPerUnit: -1 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// name not a string + bad dose + bad dosesPerUnit.
const res = await POST(
postReq({ name: 123, dose: 42, dosesPerUnit: "bad" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -24,6 +24,8 @@ vi.mock("@/lib/db", () => ({
upsert: vi.fn(),
deleteMany: vi.fn(),
},
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
// v1.4.39 QA F-H-01 — coverage probe + atomic upsert use raw SQL.
$queryRaw: vi.fn(),
@@ -97,6 +99,8 @@ beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(0) },
] as never);
// v1.4.43 W6 — default the audit-row write to resolve.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/medications/intake", () => {
@@ -396,3 +400,89 @@ describe("v1.4.39 W-MED — compliance rollup read swap", () => {
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
});
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("GET surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Bad `scope` + bad `days` (NaN-coercible string).
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=junk&days=notanumber",
),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Missing `intakeId` (min 1) + bad `status` enum.
const res = await POST(postReq({ status: "broken" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Bad intakeId (empty) + bad status + bad takenAt iso.
const res = await POST(
postReq({ intakeId: "", status: "broken", takenAt: "not-iso" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed medications.intake.update.validation-failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(postReq({ status: "broken" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
"medications.intake.update.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ status: "broken" }));
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,153 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications/intake/bulk.
* Preserves the `medications.intake.bulk.invalid` errorCode meta passthrough.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn(),
},
medicationIntakeEvent: {
create: vi.fn(),
findUnique: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { 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: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/intake/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
const res = await POST(
postReq({
entries: [
{ medicationId: "", scheduledFor: "2026-01-01T00:00:00Z" },
{ medicationId: "m1", scheduledFor: "not-iso" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
const res = await POST(
postReq({
entries: [
{ medicationId: "", scheduledFor: "2026-01-01T00:00:00Z" },
{ medicationId: "m2", scheduledFor: "not-iso" },
{ medicationId: "m3", takenAt: "also-not-iso" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
meta?: { errorCode?: string };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
});
it("writes the audit-ledger row keyed medications.intake.bulk.validation-failed", async () => {
const res = await POST(
postReq({
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
}),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("medications.intake.bulk.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
postReq({
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
}),
);
expect(res.status).toBe(422);
});
});
+23 -1
View File
@@ -36,7 +36,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -109,7 +111,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422, {
// v1.4.43 W6 — bulk intake ingest; preserve the existing
// `medications.intake.bulk.invalid` errorCode meta so the iOS
// Sync engine's retry classifier still branches on it. Audit
// breadcrumb keyed `medications.intake.bulk.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medications.intake.bulk.invalid",
});
}
+40 -2
View File
@@ -18,7 +18,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
@@ -73,7 +75,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422 + audit breadcrumb keyed
// `medications.intake.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { scope, days } = parsed.data;
@@ -261,7 +281,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — intake-event update hot path; multi-issue 422 +
// audit breadcrumb keyed `medications.intake.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.update.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.update.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
+3 -2
View File
@@ -4,8 +4,8 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { createMedicationSchema } from "@/lib/validations/medication";
@@ -110,7 +110,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = createMedicationSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more