From f92e644154135ff93c6434bb1dd26d0156c2e759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 23 May 2026 12:51:41 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.4.49=20=E2=80=94=20audit-b?= =?UTF-8?q?acklog=20closure=20+=20server=20reminder=20suppression=20+=20di?= =?UTF-8?q?agnostic=20endpoint=20+=20analytics=20cold-path=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207. --- CHANGELOG.md | 186 +- docs/api/openapi.yaml | 5287 +++++++++-------- docs/ops/attempt-number-semantics.md | 52 + docs/ops/deploy.md | 62 + docs/self-hosting/reverse-proxy.md | 22 + messages/de.json | 2 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 10 +- package.json | 2 +- .../migration.sql | 41 + .../0080_v1449_push_attempts/migration.sql | 76 + prisma/schema.prisma | 81 +- scripts/check-openapi.ts | 5 +- scripts/generate-openapi.ts | 20 +- .../diagnostic/__tests__/route.test.ts | 398 ++ .../admin/notifications/diagnostic/route.ts | 219 + src/app/api/analytics/__tests__/route.test.ts | 15 +- src/app/api/auth/me/disable-coach/route.ts | 27 +- .../__tests__/route.test.ts | 270 + .../api/auth/me/notification-prefs/route.ts | 133 + src/app/api/auth/me/route.ts | 2 +- .../dashboard/summary/__tests__/route.test.ts | 12 +- .../dashboard/widgets/__tests__/route.test.ts | 132 +- src/app/api/dashboard/widgets/route.ts | 30 +- src/app/api/devices/route.ts | 10 +- src/app/api/ingest/medication/route.ts | 8 +- src/app/api/integrations/healthkit/route.ts | 8 +- src/app/api/measurements/[id]/route.ts | 10 +- src/app/api/measurements/route.ts | 22 +- .../series/__tests__/route.test.ts | 97 + src/app/api/measurements/series/route.ts | 31 +- .../medications/[id]/intake/import/route.ts | 10 +- src/app/api/medications/[id]/intake/route.ts | 19 +- .../intake/__tests__/route.test.ts | 12 +- src/app/api/medications/intake/bulk/route.ts | 6 +- src/app/api/medications/intake/route.ts | 13 +- src/app/api/mood-entries/[id]/route.ts | 7 +- src/app/api/mood-entries/bulk/route.ts | 7 +- src/app/api/mood-entries/route.ts | 18 +- src/app/api/notifications/apns/test/route.ts | 10 +- src/app/api/tokens/route.ts | 7 +- .../withings/callback/__tests__/route.test.ts | 308 + src/app/api/withings/callback/route.ts | 133 +- .../withings/connect/__tests__/route.test.ts | 136 + src/app/api/withings/connect/route.ts | 60 +- src/components/insights/layout-coach-fab.tsx | 2 +- .../dashboard-layout-section.test.tsx | 6 +- .../__tests__/disable-coach-card.test.tsx | 29 +- .../__tests__/mood-reminder-card.test.tsx | 77 + src/components/settings/about-section.tsx | 71 +- src/components/settings/account-section.tsx | 73 +- src/components/settings/ai-section.tsx | 36 +- .../settings/dashboard-layout-section.tsx | 85 +- .../settings/mood-reminder-card.tsx | 39 +- src/components/ui/__tests__/textarea.test.tsx | 14 - src/hooks/_internal/use-query-client-safe.ts | 33 + src/hooks/use-disable-coach.ts | 20 +- src/hooks/use-feature-flags.ts | 11 +- src/lib/__tests__/api-response-zod.test.ts | 41 + .../__tests__/summaries-slice.test.ts | 88 +- src/lib/analytics/summaries-slice.ts | 101 +- src/lib/api-response.ts | 85 +- .../__tests__/coach-user-disable.test.tsx | 203 + .../__tests__/admin-alert.test.ts | 4 +- src/lib/integrations/__tests__/status.test.ts | 14 +- src/lib/integrations/status.ts | 8 +- .../integration-status-null-probe.test.ts | 75 + src/lib/jobs/integration-status-null-probe.ts | 49 + src/lib/jobs/reminder-worker.ts | 121 + .../senders/__tests__/apns.test.ts | 7 + .../__tests__/push-attempt-record.test.ts | 537 ++ src/lib/notifications/senders/apns.ts | 41 +- src/lib/notifications/senders/ntfy.ts | 21 + .../senders/push-attempt-record.ts | 62 + src/lib/notifications/senders/telegram.ts | 14 + src/lib/notifications/senders/web-push.ts | 42 + .../__tests__/redact-payload.test.ts | 130 + src/lib/observability/redact-payload.ts | 79 + .../onboarding/__tests__/tour-restart.test.ts | 77 + src/lib/onboarding/tour-restart.ts | 83 + src/lib/openapi/routes.ts | 150 + .../__tests__/notification-prefs.test.ts | 146 + src/lib/validations/notification-prefs.ts | 128 + .../integration/workout-batch-create.test.ts | 26 +- tests/integration/workout-batch-race.test.ts | 21 +- 87 files changed, 7939 insertions(+), 2950 deletions(-) create mode 100644 docs/ops/attempt-number-semantics.md create mode 100644 docs/ops/deploy.md create mode 100644 prisma/migrations/0079_v1449_user_notification_prefs/migration.sql create mode 100644 prisma/migrations/0080_v1449_push_attempts/migration.sql create mode 100644 src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts create mode 100644 src/app/api/admin/notifications/diagnostic/route.ts create mode 100644 src/app/api/auth/me/notification-prefs/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/notification-prefs/route.ts create mode 100644 src/app/api/withings/callback/__tests__/route.test.ts create mode 100644 src/app/api/withings/connect/__tests__/route.test.ts create mode 100644 src/components/settings/__tests__/mood-reminder-card.test.tsx create mode 100644 src/hooks/_internal/use-query-client-safe.ts create mode 100644 src/lib/jobs/__tests__/integration-status-null-probe.test.ts create mode 100644 src/lib/jobs/integration-status-null-probe.ts create mode 100644 src/lib/notifications/senders/__tests__/push-attempt-record.test.ts create mode 100644 src/lib/notifications/senders/push-attempt-record.ts create mode 100644 src/lib/observability/__tests__/redact-payload.test.ts create mode 100644 src/lib/observability/redact-payload.ts create mode 100644 src/lib/onboarding/__tests__/tour-restart.test.ts create mode 100644 src/lib/onboarding/tour-restart.ts create mode 100644 src/lib/validations/__tests__/notification-prefs.test.ts create mode 100644 src/lib/validations/notification-prefs.ts 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 `