diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8d22234..c7cfc36a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [1.4.49] — 2026-05-23 — Audit-backlog closure + server-side reminder suppression + diagnostic endpoint backed + +v1.4.47 closed the H1 + Mediums punch list. v1.4.49 bundles the deferred v1.4.47 Mediums and Lows together with the v1.4.48-deferred items: server-side suppression of `MEDICATION_REMINDER` APNs for iOS clients that manage their own local reminders, a `push_attempts` table backing the diagnostic endpoint, the `/api/admin/notifications/diagnostic` OpenAPI entry, and a sweep of v1.4.48 QA forward-findings (Withings reason-tagging, observability PII hardening, MoodReminderCard auto-clear parity, simplifier dead-code, sub-locale copy gaps). The workout-batch integration suite that had been red on `main` for three releases turns green; the cold-mount analytics fallback path picks up the same 90-day outer cap that v1.4.47.1 shipped for the rollup-fresh path; iOS validation-failure audit rows carry the rejected payload shape so iOS serialiser drift can be chased from a single log line. + +### Added + +- `/api/admin/notifications/diagnostic` — admin-only endpoint that surfaces device APNs token presence (masked to an 8-char prefix + 8-char suffix), notification channel config presence, and recent push attempts for the calling account. Closes the DB-shell bottleneck that slowed the v1.4.47 APNs investigation. The endpoint is registered in `docs/api/openapi.yaml` so future iOS clients can codegen against it. +- `push_attempts` table backs the diagnostic endpoint's `recentPushAttempts` field. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row per attempt (channel + eventType + result + reason + createdAt). The diagnostic endpoint now returns the last 20 attempts ordered by recency. Daily cleanup cron at 03:35 Europe/Berlin prunes rows older than 90 days. +- `GET` + `PATCH /api/auth/me/notification-prefs` — per-user notification preferences with deep-merge semantics. Initial category `medication.clientManaged: boolean` (default `false`); future categories slot in without overwriting siblings. The cron `medicationReminderJob` skips dose-due APNs when the flag is `true`, emitting a `medication_reminder.suppressed_client_managed` wide-event annotation per skip. Other notification kinds (mood reminder, personal record, system alert) are unaffected. Closes the server side of GitHub issue #206. +- `redactSensitiveFields(body)` helper at `src/lib/observability/redact-payload.ts` with a denylist (`password` / `token` / `secret` / `apiKey` / `authorization` / `csrfState` / `nonce`, case-insensitive, recursive). Used by the wide-event `received_shape_excerpt` surface so future free-text routes adopting the pattern cannot leak credentials. +- Native sub-locale translations for `settings.about.tourReplay` and `tourReplayHint` (es / fr / it / pl), and for `settings.about.linksHeading` + `newerAvailable` across the same four sub-locales. Tightened `dashboard.dragHandleHint` copy in de + pl (was ~30 words on a `sr-only` paragraph; screen readers spent ~6 s reading it on focus). +- OpenAPI documentation for `GET` + `PATCH /api/auth/me/disable-coach`. +- Operations runbook notes: `attemptNumber` audit-row vs alert-signal semantics at `docs/ops/attempt-number-semantics.md`, and explicit migration-before-container ordering at `docs/ops/deploy.md` for manual / partial-deploy scenarios. + +### Changed + +- `/api/analytics` cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (`count` / `min` / `max` / `mean`) keep the full-partition scan; the windowed 7 / 30 / 90-day `avg` / `slope` / `r²` columns take a 90-day outer cap so the planner does an index range scan on `(user_id, type, measured_at)` instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. +- Dashboard reorder + onboarding tour helpers consolidated. Arrow-button reorder delegates to the same swap path as drag-and-drop. `restartOnboardingTour` extracted so About + Account sections share the surface. `useSortable` honours `prefers-reduced-motion`. Drag-handle `aria-describedby` hint gates on visible widget count so the empty-state cannot orphan the hint paragraph. +- Coach disable surface tightened. The status banner auto-clears after 3 s. `PATCH /api/auth/me/disable-coach` body migrated to Zod + `returnAllZodIssues` for the uniform 422 envelope. The `eslint-disable react-hooks/rules-of-hooks` line in `useDisableCoach` is replaced with a shared `useQueryClientMounted` helper that both `useFeatureFlags` and `useDisableCoach` consume. +- Withings OAuth callback consumes the nonce atomically. `delete` is issued first; the `P2025` catch handles the replay branch. The previous read-then-delete pattern allowed a theoretical race between two concurrent callbacks with the same nonce. Silent `.catch(() => {})` on delete failures now surface via a Wide-Event warning so a real infra failure reaches the audit trail. +- Withings callback `?reason=state` redirect is now differentiated into four distinct reason tags so operators can tell `csrf1` (URL/cookie mismatch), `replay` (nonce already consumed), `expired` (TTL elapsed), and `cross_user` (session/row userId mismatch) apart without DB-shell access. The delete-failure warning template interpolates `err.name` rather than `${err}` so Prisma error messages cannot echo offending values into the audit log. +- Withings `connect` endpoint rate-limited to 10 calls per 60 s per user (mirroring the disable-coach pattern); row-create failures redirect to the consistent error surface (`/settings/integrations?withings=error&reason=connect`) instead of bubbling a 500. +- `buildPayloadDiagnostic(body)` helper extracted to `src/lib/api-response.ts`. The dashboard widgets PUT route and the measurements series GET route now route their bodies through `redactSensitiveFields` first then call the shared helper — single source of truth for the iOS payload-diff wide-event shape, redaction layered in front composably. +- `sanitiseZodIssues(issues, { stripValuesFromMessage: true })` overload added; 14 audit-log sites that wrote `JSON.stringify({ issues })` to `details` now strip the `message` string to a `{ path, code }` pair. Most impactful catch: the `/api/devices` route's `invalid_format` issues were echoing `apnsToken` values verbatim into the audit log. +- `disableCoachBody` + `disableCoachData` Zod schemas collapsed into a single `disableCoachFlag` schema in the OpenAPI route table; `zod-openapi` emits the request + response pair from the one source. +- `yaml@2` emitter no longer sorts map entries during `pnpm openapi:generate`. Alphabetical sort had placed alias references before their anchors when `.meta()`-tagged sub-schemas appeared inside `z.array(...)`. Output stability is preserved by Zod's declaration-order guarantee. +- Worker boot emits a Wide-Event warning if any `integration_statuses` row still has a NULL per-kind counter after the v1.4.47 legacy-column drop. Closes the silent alert-ladder gap where such rows would alert two strikes later than they should. +- `MoodReminderCard` status banner auto-clears after 3 s — parity with `DisableCoachCard` (the existing docstring claimed parity that the implementation lacked). +- `mergeReorderIntoLayout` emits a dev-only `console.warn` when a reorder id has no matching widget in the layout. Statically unreachable today; defence-in-depth for the upcoming per-tile Suspense refactor that introduces dynamic widgets. + +### Fixed + +- Workout-batch integration tests (`workout-batch-create`, `workout-batch-race`) updated to match the v1.4.42 cross-source dedup semantics. The integration suite had been red on `main` since v1.4.45 (three release commits shipped through it). Suite is green again. +- Twelve stale "migration 0076" references in code comments corrected. `consecutiveFailures` sites read 0077; `disable-coach` sites read 0078. Comment-only sweep, no behavioural change. +- `docs/ops/attempt-number-semantics.md` referenced migration 0076 for the dropped `consecutiveFailures` column; the actual migration is 0077. +- Per-channel APNs "Send test" button (Settings → notification channels) now fires with `eventType: "MEDICATION_REMINDER"` so the dispatcher's `time-sensitive` interruption-level + `apns-priority: 10` branch runs. The previous `SYSTEM_ALERT` event-type used the dispatcher's default `active` level, which iOS may summarise into the Notification Center rather than presenting a lock-screen banner — leaving the user with no signal whether a real dose reminder will surface visibly. A successful test now exercises the exact path production reminders take. Title and body strings still read "Test notification" so the push cannot be mistaken for an actual scheduled dose. + +### Performance + +- Cold-fallback `/api/analytics` slim slice — `aggregates` query split so the windowed columns get a 90-day outer `WHERE` cap, matching the inner `FILTER` windows. Multi-second TTFB on cold mounts drops to sub-second on tenants with large measurement partitions. The `narrows` query received the same cap in v1.4.47.1. + +### Security + +- Withings `connect` rate-limited (per-user 10 / 60 s) to bound ledger-row growth from a misbehaving client. +- Coach `disable-coach` validation envelope unified through `returnAllZodIssues` for a uniform 422 shape. + +### Observability + +- `dashboard.widgets.validation-failed` and `measurements.series.validation-failed` audit rows now carry `received_keys`, a truncated `received_shape_excerpt` (256-char hard cap), and the sanitised Zod issues. The excerpt is generated through `redactSensitiveFields` so credential-shaped keys (password / token / secret / apiKey / authorization / csrfState / nonce) land as the literal `"[redacted]"` instead of their raw values. One log line per validation failure carries both the iOS-sent shape and the server's rejection reason, so iOS serialiser drift can be chased without DB-shell access. +- Push attempts persisted in `push_attempts` for every channel (APNS / Web-Push / Telegram / NTFY). The diagnostic endpoint reads the last 20 per user; the daily cron at 03:35 Europe/Berlin prunes attempts older than 90 days. Operators can now grep the wide-event `notifications.send` annotation OR query the table directly for an APNs incident triage. +- Withings callback redirect reasons emit matching `meta.reason` annotations (`csrf1` / `replay` / `expired` / `cross_user`) so the wide-event stream carries the same differentiation the URL query param does. + +### Documentation + +- `NEXT_PUBLIC_APP_VERSION`, `NEXT_PUBLIC_APP_BUILD_SHA`, `NEXT_PUBLIC_APP_BUILT_AT` documented as build-time-only env-vars in `docs/self-hosting/reverse-proxy.md`. The Coolify runtime entry was misleading; Next.js bakes the values into the bundle at build time via the `next.config.ts` `env:` block. + +### Test hygiene + +- New grep-discovery test mirrors the `flags.coach` discovery test for `user.disableCoach` / `useDisableCoach`. New sites must be added to the allowlist explicitly so CI fails on an unreviewed addition. +- `target-card.tsx` per-card "Ask the coach" CTA pinned in the user-disable fixture (the cascade fixture already covered the page-level gate). +- `textarea` `forwardRef` `Symbol.for("react.forward_ref")` sentinel assertion deleted (React-internal API). Ref forwarding is covered indirectly by the call sites that pass `ref` to `