From 8528c3b4fef9922361fbed80f66cc354f19deae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 29 May 2026 22:07:10 +0200 Subject: [PATCH] chore(release): v1.5.6 Pure-history medication detail page, legacy step consolidation, and the finish of the outbound-fetch migration. - /medications/[id] becomes a read-only history surface; every setting moves into a new advanced-settings sheet, and the edit trigger becomes a two-option picker. The list page is unchanged. - A pg-boss job folds pre-v1.5.0 granular step rows into one canonical daily total per user per day and soft-deletes the originals; migration 0087 adds a partial index for the converging discovery scan. - Every remaining server-side fetch routes through safeFetch (Withings, Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new healthlog/safe-fetch-required ESLint rule guards the boundary. - Fixes: intake-edit dialog seeded from real values, consolidation skips a unique-constraint day instead of stranding it, avatar upload reads the body once under a bounded cap, egress lint rejects absolute URLs. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 30 ++ CLAUDE.md | 1 + docs/api/openapi.yaml | 199 +++++++++- eslint-plugins/healthlog/index.js | 19 + .../healthlog/safe-fetch-required.js | 138 +++++++ eslint.config.mjs | 15 +- messages/de.json | 7 + messages/en.json | 7 + messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/pl.json | 9 +- package.json | 2 +- .../migration.sql | 38 ++ prisma/schema.prisma | 5 + .../api/admin/monitoring/umami-test/route.ts | 21 +- src/app/api/bugreport/route.ts | 2 +- src/app/api/monitoring/umami-script/route.ts | 13 +- src/app/api/send/route.ts | 23 +- .../api/user/avatar/__tests__/route.test.ts | 35 ++ src/app/api/user/avatar/route.ts | 84 ++++- src/app/medications/[id]/page.tsx | 190 ++++------ .../detail-page-history-surface.test.ts | 48 +++ .../advanced-settings-sheet.test.tsx | 137 +++++++ .../intake-edit-dialog-seed.test.tsx | 79 ++++ .../medication-card-symmetry.test.tsx | 7 +- .../medication-detail-header.test.tsx | 48 ++- .../medications/advanced-settings-sheet.tsx | 101 +++++ .../medications/cadence-summary-row.tsx | 3 +- .../medications/glp1-medication-card.tsx | 4 +- .../medications/intake-history-list-v2.tsx | 11 +- .../medications/medication-card.tsx | 4 +- .../medications/medication-detail-header.tsx | 61 ++- .../settings-section-phase-swap.test.tsx | 71 ++++ .../sections/destructive-zone-section.tsx | 11 + .../sections/intake-history-preview.tsx | 29 +- .../medications/sections/settings-section.tsx | 18 +- .../wizard/MedicationWizardDialog.tsx | 26 +- .../wizard/__tests__/wizard-payload.test.ts | 57 --- .../medications/wizard/wizard-payload.ts | 34 +- src/lib/ai/codex-client.ts | 8 +- src/lib/ai/codex-oauth.ts | 9 +- src/lib/jobs/reminder-worker.ts | 66 ++++ src/lib/jobs/step-consolidation.ts | 137 +++++++ src/lib/logging/transports.ts | 4 +- .../consolidate-legacy-steps.test.ts | 80 ++++ .../measurements/consolidate-legacy-steps.ts | 350 ++++++++++++++++++ src/lib/monitoring/glitchtip.ts | 60 +-- src/lib/openapi/routes.ts | 87 +++++ src/lib/queries/use-analytics-query.ts | 7 +- src/lib/withings/client.ts | 11 +- src/lib/withings/sync-activity.ts | 3 +- src/lib/withings/sync-sleep.ts | 3 +- .../consolidate-legacy-steps.test.ts | 228 ++++++++++++ 54 files changed, 2343 insertions(+), 324 deletions(-) create mode 100644 eslint-plugins/healthlog/index.js create mode 100644 eslint-plugins/healthlog/safe-fetch-required.js create mode 100644 prisma/migrations/0087_v156_step_consolidation/migration.sql create mode 100644 src/app/medications/__tests__/detail-page-history-surface.test.ts create mode 100644 src/components/medications/__tests__/advanced-settings-sheet.test.tsx create mode 100644 src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx create mode 100644 src/components/medications/advanced-settings-sheet.tsx create mode 100644 src/components/medications/sections/__tests__/settings-section-phase-swap.test.tsx create mode 100644 src/lib/jobs/step-consolidation.ts create mode 100644 src/lib/measurements/__tests__/consolidate-legacy-steps.test.ts create mode 100644 src/lib/measurements/consolidate-legacy-steps.ts create mode 100644 tests/integration/consolidate-legacy-steps.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 38761815e..c9b860891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [1.5.6] — 2026-05-29 — Pure-history detail page, legacy step consolidation, egress finish + +v1.5.5 gave every retired medication feature a home on the detail page, but the page still carried the create/edit affordances that belong on the list. This release turns `/medications/[id]` into a pure history surface, collapses every setting into one advanced sheet, consolidates the pre-v1.5.0 granular step rows that bloated the per-sample read path, and finishes the outbound-fetch migration the `safeFetch` wrapper started. + +### Added + +- **Legacy step consolidation** — a pg-boss job (`STEP_CONSOLIDATION_QUEUE`) folds pre-v1.5.0 granular `ACTIVITY_STEPS` rows into one canonical daily-total row per user per day, anchored to the user's timezone, and soft-deletes the originals. A day that already carries a post-v1.5.0 `stats:` total is left untouched (no double-count) while its legacy rows are still tombstoned. Boot-time discovery enqueues only users still holding live legacy rows, so the pass converges to zero work and is idempotent across reboots. Migration `0087` adds a partial index scoped to live step rows to keep the discovery scan cheap. +- **`healthlog/safe-fetch-required` ESLint rule** — bans raw `fetch(` outside the wrapper, exempting only genuine same-origin relative paths. Positioned as the author-time egress floor. + +### Changed + +- **`/medications/[id]` is now a pure history view** — the Today's-dose card stays on the list page; the detail page reads the medication's past intake, cadence summary, and trends only. Every setting (notifications, phase config, grace window, pause/resume, end, purge) moves into a new `` reached from the header. The edit trigger becomes a two-option picker (plan vs. advanced). The `/medications` list page is unchanged apart from matching the detail-nav card glyph to its destination. +- **Outbound-fetch migration finished** — every remaining server-side fetch routes through `safeFetch`: Withings (token exchange, refresh, measurements, webhook subscribe/unsubscribe, activity + sleep sync), Codex OAuth + SSE client, the bug-report GitHub comment, and the operator-host calls (Umami script/send/test, GlitchTip envelope + store, Loki). Credential-carrying constant hosts inherit the `redirect: "manual"` default so secrets cannot replay onto a redirect hop; operator-supplied hosts additionally pin `requirePublicHost` for the connect-time IP guard. +- **`/api/insights/layout`** is registered in the OpenAPI spec so the contract matches the shipped route. + +### Fixed + +- **Intake-edit dialog opened empty** — editing a logged intake row now seeds the dialog from the row's real `takenAt` / `skipped` instead of an empty stub. +- **Step consolidation could strand a day** — a minted daily total can collide on the second unique index when a manual step row already sits at the day's canonical-noon instant; the collision rolled back the in-transaction soft-delete and the day re-appeared on every boot. The pass now catches the constraint violation, logs and counts the skip, and steps to the next day. +- **Avatar upload over-buffered on the abort path** — the upload body is now read once through a bounded reader that drops retained bytes past the 2 MiB cap, replacing the clone + parallel parse that left the native parse buffering an oversized body after the cap tripped. +- **Egress lint exemption let absolute URLs through** — the same-origin exemption now rejects protocol-relative (`//host`) and backslash (`/\host`) forms, which both resolve off-origin. + +### Removed + +- **Dead wizard landing-intent path** — the pure-history rewrite removed the last caller that passed a landing intent into the medication wizard, so the prop, its state-key segment, and the intent branch in `landingStepForEdit` are gone; the helper reduces to the schedule-count heuristic. + +### Tests + +- 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green. + ## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes. diff --git a/CLAUDE.md b/CLAUDE.md index bec3cac1c..8227d811a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,7 @@ The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of pe - **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`. - **SSRF: input-time `isPublicUrl` is the floor.** The helper in `src/lib/validations/notifications.ts:64` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`. +- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction. - **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket. - **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`. - **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 40bfda4e7..3d9b99bbc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.5.4 + version: 1.5.5 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1525,6 +1525,60 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a2 "429": *a3 + /api/insights/layout: + get: + tags: + - Insights + summary: Read the calling user's Insights tile layout + description: Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the + user has not customised it. Mirrors the dashboard-widgets contract. + responses: + "200": + description: The resolved layout (custom or default). + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsLayout" + "401": *a1 + "422": *a2 + "429": *a3 + put: + tags: + - Insights + summary: Replace the calling user's Insights tile layout + description: Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 + envelope, matching the dashboard-widgets route. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsLayoutBody" + responses: + "200": + description: Layout saved; the normalised layout is echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsLayoutSaved" + "401": *a1 + "422": *a2 + "429": *a3 + delete: + tags: + - Insights + summary: Reset the calling user's Insights tile layout + description: Clears the persisted layout and returns the default layout. Idempotent. + responses: + "200": + description: Layout reset; the default layout is returned. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsLayoutReset" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/chat/messages/{id}/feedback: post: tags: @@ -2081,6 +2135,51 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + InsightsLayoutBody: + type: object + properties: + version: + type: number + const: 1 + tiles: + minItems: 1 + maxItems: 50 + type: array + items: + type: object + properties: + id: + type: string + enum: + - overview + - blutdruck + - puls + - sauerstoff + - koerpertemperatur + - gewicht + - bmi + - aktive-energie + - workouts + - schlaf + - ruhepuls + - hrv + - stimmung + - medikamente + visible: + type: boolean + order: + type: integer + minimum: 0 + maximum: 99 + required: + - id + - visible + - order + required: + - version + - tiles + description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout + schema version; tile ids are a closed enum derived from the server's tile registry." CoachMessageFeedbackRequest: type: object properties: @@ -4312,6 +4411,57 @@ components: - data - error additionalProperties: false + InsightsLayout: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsLayoutBodyOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsLayoutSaved: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsLayoutBodyOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsLayoutReset: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsLayoutBodyOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false CoachMessageFeedbackResponse: type: object properties: @@ -4620,6 +4770,53 @@ components: - disableCoach additionalProperties: false description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + InsightsLayoutBodyOutput: + type: object + properties: + version: + type: number + const: 1 + tiles: + minItems: 1 + maxItems: 50 + type: array + items: + type: object + properties: + id: + type: string + enum: + - overview + - blutdruck + - puls + - sauerstoff + - koerpertemperatur + - gewicht + - bmi + - aktive-energie + - workouts + - schlaf + - ruhepuls + - hrv + - stimmung + - medikamente + visible: + type: boolean + order: + type: integer + minimum: 0 + maximum: 99 + required: + - id + - visible + - order + additionalProperties: false + required: + - version + - tiles + additionalProperties: false + description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout + schema version; tile ids are a closed enum derived from the server's tile registry." Medication: type: object properties: diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js new file mode 100644 index 000000000..03d4349aa --- /dev/null +++ b/eslint-plugins/healthlog/index.js @@ -0,0 +1,19 @@ +/** + * @fileoverview Aggregates the project-local `healthlog/*` ESLint rules + * into a single plugin object for the flat config. + * + * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. + * - `safe-fetch-required` — outbound fetch must route through safeFetch. + */ + +"use strict"; + +const queryKeyFactory = require("./queryKey-factory.js"); +const safeFetchRequired = require("./safe-fetch-required.js"); + +module.exports = { + rules: { + "queryKey-factory": queryKeyFactory.rules["queryKey-factory"], + "safe-fetch-required": safeFetchRequired, + }, +}; diff --git a/eslint-plugins/healthlog/safe-fetch-required.js b/eslint-plugins/healthlog/safe-fetch-required.js new file mode 100644 index 000000000..7a468efb0 --- /dev/null +++ b/eslint-plugins/healthlog/safe-fetch-required.js @@ -0,0 +1,138 @@ +/** + * @fileoverview ESLint rule — outbound `fetch` must route through the + * `safeFetch` wrapper. + * + * v1.5.6 — `src/lib/safe-fetch.ts` is the documented egress entry point: + * it pins `redirect: "manual"` + an `AbortSignal.timeout` by default and + * gates the connect-time IP pin behind `requirePublicHost`. A raw + * `fetch(...)` call under `src/lib/` or `src/app/` bypasses every one of + * those defences, so this rule flags it at authoring time and in CI. + * + * Allowed: + * - `safeFetch(...)` — the wrapper itself. + * - calls inside `src/lib/safe-fetch.ts` — the wrapper's own `fetch`. + * - calls inside `src/lib/safe-fetch-dispatcher.ts` — the pinned dispatcher. + * - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they + * mock or assert against `fetch` directly. + * + * The check is a syntactic `CallExpression` match against a bare + * `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` / + * `window.fetch(` member forms. `safeFetch` is a distinct identifier and + * never matches. + * + * Same-origin client fetches are exempt: a first argument that is a + * string literal (or template head) starting with `/` is a relative, + * same-origin request (`fetch("/api/…")`) that never leaves the origin + * and has no SSRF / redirect-leak / DNS-rebinding surface. The wrapper + * is for OUTBOUND egress to external hosts — absolute URLs or + * variable-sourced targets — which is exactly what stays flagged. + * + * @see src/lib/safe-fetch.ts + * @see src/lib/safe-fetch-dispatcher.ts + */ + +"use strict"; + +// Files exempt from the rule — the wrapper internals and the test +// surface. Posix-style suffix matches against the absolute filename via +// `String#includes`, mirroring the queryKey-factory rule's convention. +const EXEMPT_FILES = [ + "src/lib/safe-fetch.ts", + "src/lib/safe-fetch-dispatcher.ts", +]; + +// Only enforce inside the application source roots. Scripts, config, and +// generated code are out of scope. +const ENFORCED_ROOTS = ["src/lib/", "src/app/"]; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; + if (EXEMPT_FILES.some((f) => posix.includes(f))) return false; + if (isTestFile(posix)) return false; + return true; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const safeFetchRequiredRule = { + meta: { + type: "problem", + docs: { + description: + "Outbound fetch must route through the safeFetch wrapper (src/lib/safe-fetch.ts).", + }, + schema: [], + messages: { + rawFetch: + "Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.", + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + + function isFetchCallee(callee) { + // Bare `fetch(...)`. + if (callee.type === "Identifier" && callee.name === "fetch") return true; + // `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`. + if ( + callee.type === "MemberExpression" && + !callee.computed && + callee.property.type === "Identifier" && + callee.property.name === "fetch" && + callee.object.type === "Identifier" && + (callee.object.name === "globalThis" || + callee.object.name === "window" || + callee.object.name === "self") + ) { + return true; + } + return false; + } + + function isSameOriginRelative(arg) { + if (!arg) return false; + // A single leading `/` not followed by `/` or `\`. `//evil.com` is a + // protocol-relative absolute URL and `/\evil.com` normalises to the + // same under WHATWG parsing — both resolve off-origin, so neither is + // a same-origin path and the wrapper must still cover them. + const isRelativeHead = (head) => /^\/(?![/\\])/.test(head); + // `fetch("/api/…")` + if (arg.type === "Literal" && typeof arg.value === "string") { + return isRelativeHead(arg.value); + } + // `fetch(`/api/${id}`)` — inspect the template's first cooked chunk. + if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) { + return isRelativeHead(arg.quasis[0].value.cooked ?? ""); + } + return false; + } + + return { + CallExpression(node) { + if (!isFetchCallee(node.callee)) return; + // Exempt same-origin relative-path requests — they never leave + // the origin, so the wrapper's outbound defences do not apply. + if (isSameOriginRelative(node.arguments[0])) return; + context.report({ node, messageId: "rawFetch" }); + }, + }; + }, +}; + +module.exports = safeFetchRequiredRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 37d18ab88..74782b5a5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; -import healthlogPlugin from "./eslint-plugins/healthlog/queryKey-factory.js"; +import healthlogPlugin from "./eslint-plugins/healthlog/index.js"; const eslintConfig = defineConfig([ ...nextVitals, @@ -17,6 +17,10 @@ const eslintConfig = defineConfig([ // worktree on an older commit can't poison `pnpm lint` locally. // CI never sees this path; the rule is for the dev environment. ".claude/**", + // The project-local ESLint rule plugin is CommonJS (require/module + // .exports) by ESLint convention; it is not application source and + // is not linted by the app's TypeScript ruleset. + "eslint-plugins/**", ]), // v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array // `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the @@ -27,7 +31,14 @@ const eslintConfig = defineConfig([ // whitelist + rationale. { plugins: { healthlog: healthlogPlugin }, - rules: { "healthlog/queryKey-factory": "error" }, + rules: { + "healthlog/queryKey-factory": "error", + // v1.5.6 — every outbound fetch under src/lib + src/app must route + // through the safeFetch wrapper (manual-redirect + timeout, and the + // requirePublicHost connect-time pin). The wrapper internals and + // test files are exempt; see the rule file for the allowlist. + "healthlog/safe-fetch-required": "error", + }, }, ]); diff --git a/messages/de.json b/messages/de.json index e56bfa9ea..f4059c01e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1256,6 +1256,13 @@ "tokenCopied": "Token kopiert", "copyToken": "Token kopieren", "mintedHint": "Token jetzt kopieren — es wird nur einmal angezeigt." + }, + "edit": { + "planOption": "Plan bearbeiten", + "advancedOption": "Erweiterte Einstellungen" + }, + "advanced": { + "title": "Erweiterte Einstellungen" } } }, diff --git a/messages/en.json b/messages/en.json index cef1937d8..fce7e5b4d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1256,6 +1256,13 @@ "tokenCopied": "Token copied", "copyToken": "Copy token", "mintedHint": "Copy this token now — it is shown only once." + }, + "edit": { + "planOption": "Edit plan", + "advancedOption": "Advanced settings" + }, + "advanced": { + "title": "Advanced settings" } } }, diff --git a/messages/es.json b/messages/es.json index 7894dcee7..2c6841973 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1256,6 +1256,13 @@ "tokenCopied": "Token copied", "copyToken": "Copy token", "mintedHint": "Copy this token now — it is shown only once." + }, + "edit": { + "planOption": "Editar plan", + "advancedOption": "Ajustes avanzados" + }, + "advanced": { + "title": "Ajustes avanzados" } } }, @@ -3537,4 +3544,4 @@ "dailyTitle": "Registrar el ánimo", "dailyBody": "¿Cómo te sientes hoy?" } -} \ No newline at end of file +} diff --git a/messages/fr.json b/messages/fr.json index bd9492040..2877f4b46 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1256,6 +1256,13 @@ "tokenCopied": "Token copied", "copyToken": "Copy token", "mintedHint": "Copy this token now — it is shown only once." + }, + "edit": { + "planOption": "Modifier le plan", + "advancedOption": "Paramètres avancés" + }, + "advanced": { + "title": "Paramètres avancés" } } }, @@ -3537,4 +3544,4 @@ "dailyTitle": "Enregistrer votre humeur", "dailyBody": "Comment vous sentez-vous aujourd’hui ?" } -} \ No newline at end of file +} diff --git a/messages/it.json b/messages/it.json index a6a0c65cc..dda27085d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1256,6 +1256,13 @@ "tokenCopied": "Token copied", "copyToken": "Copy token", "mintedHint": "Copy this token now — it is shown only once." + }, + "edit": { + "planOption": "Modifica piano", + "advancedOption": "Impostazioni avanzate" + }, + "advanced": { + "title": "Impostazioni avanzate" } } }, @@ -3537,4 +3544,4 @@ "dailyTitle": "Registra il tuo umore", "dailyBody": "Come ti senti oggi?" } -} \ No newline at end of file +} diff --git a/messages/pl.json b/messages/pl.json index af81a8bdb..540f7d1c2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1256,6 +1256,13 @@ "tokenCopied": "Token copied", "copyToken": "Copy token", "mintedHint": "Copy this token now — it is shown only once." + }, + "edit": { + "planOption": "Edytuj plan", + "advancedOption": "Ustawienia zaawansowane" + }, + "advanced": { + "title": "Ustawienia zaawansowane" } } }, @@ -3537,4 +3544,4 @@ "dailyTitle": "Zapisz nastrój", "dailyBody": "Jak się dzisiaj czujesz?" } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 8204785bd..73db0d615 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.5.5", + "version": "1.5.6", "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", diff --git a/prisma/migrations/0087_v156_step_consolidation/migration.sql b/prisma/migrations/0087_v156_step_consolidation/migration.sql new file mode 100644 index 000000000..724d72496 --- /dev/null +++ b/prisma/migrations/0087_v156_step_consolidation/migration.sql @@ -0,0 +1,38 @@ +-- v1.5.6 — accelerate the legacy step-consolidation discovery query. +-- +-- The boot-time consolidation pass (`enqueueBootTimeStepConsolidation` +-- in src/lib/jobs/step-consolidation.ts) repeatedly scans for users +-- still holding live pre-v1.5.0 granular step rows: `ACTIVITY_STEPS` +-- rows that are not tombstoned and whose `external_id` is NULL or does +-- not carry the `stats:HKQuantityTypeIdentifierStepCount:` daily-total +-- prefix. On an account with a multi-year HealthKit history that +-- predicate matches hundreds-of-thousands of rows before the +-- consolidation tombstones them. +-- +-- A partial index keyed on the legacy-candidate predicate keeps both +-- the per-boot discovery scan and the per-user `findMany` cheap, and — +-- because the index only covers `deleted_at IS NULL` rows — it shrinks +-- to (eventually) empty as the pass converges, so it carries no +-- steady-state write cost once every legacy row is consolidated. +-- +-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column +-- add, no backfill, no rewrite of `measurements`. +-- +-- Lock note (conscious trade-off): this is a plain, non-`CONCURRENTLY` +-- index build, so it runs inside Prisma's migration transaction and +-- holds an ACCESS EXCLUSIVE lock on `measurements` for the duration of +-- the build — reads and writes to the table block until it completes. +-- The `deleted_at IS NULL` + `type = 'ACTIVITY_STEPS'` partial predicate +-- keeps the index tiny (most tenants carry no legacy step rows at all, +-- and the covered set shrinks to empty as the pass converges), so on a +-- typical self-host the build is sub-second and the lock is not felt. +-- The largest multi-year HealthKit tenants are the ones who both need +-- this index and would feel a longer lock; for those, run the deploy at +-- a low-traffic window. A `CONCURRENTLY` build cannot run inside the +-- migration transaction and is not warranted given the partial-index +-- size on the overwhelming majority of deployments. + +CREATE INDEX IF NOT EXISTS "measurements_legacy_step_consolidation_idx" + ON "measurements" ("user_id", "measured_at") + WHERE "type" = 'ACTIVITY_STEPS' + AND "deleted_at" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ad04de413..016687425 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -610,6 +610,11 @@ model Measurement { @@unique([userId, type, source, externalId]) @@index([userId, type, measuredAt]) @@index([externalId]) + // `measurements_legacy_step_consolidation_idx` is a partial index + // over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see + // migration 0087. It accelerates the v1.5.6 legacy step-consolidation + // discovery scan. Prisma can't express a partial-predicate index, so + // it lives in raw migration SQL and is intentionally omitted here. @@map("measurements") } diff --git a/src/app/api/admin/monitoring/umami-test/route.ts b/src/app/api/admin/monitoring/umami-test/route.ts index 660568ce5..c030f2df1 100644 --- a/src/app/api/admin/monitoring/umami-test/route.ts +++ b/src/app/api/admin/monitoring/umami-test/route.ts @@ -89,15 +89,20 @@ export const POST = apiHandler(async (request: NextRequest) => { let lastDetails = ""; for (const targetUrl of targetUrls) { - const upstream = await safeFetch(targetUrl, { - method: "POST", - headers: { - "content-type": "application/json", - "user-agent": "healthlog-admin-monitoring-test", + const upstream = await safeFetch( + targetUrl, + { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "healthlog-admin-monitoring-test", + }, + body: JSON.stringify(payload), + cache: "no-store", }, - body: JSON.stringify(payload), - cache: "no-store", - }); + // Operator-configured Umami host — pin the connect-time IP. + { requirePublicHost: true }, + ); if (upstream.ok) { return apiSuccess({ diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts index 3f8ad4cc5..0a9aedc55 100644 --- a/src/app/api/bugreport/route.ts +++ b/src/app/api/bugreport/route.ts @@ -159,7 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // GitHub doesn't support base64 in comments, so we note the limitation const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`; - await fetch( + await safeFetch( `https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`, { method: "POST", diff --git a/src/app/api/monitoring/umami-script/route.ts b/src/app/api/monitoring/umami-script/route.ts index 4460c7f9c..3c7d7925f 100644 --- a/src/app/api/monitoring/umami-script/route.ts +++ b/src/app/api/monitoring/umami-script/route.ts @@ -26,9 +26,16 @@ export const GET = apiHandler(async () => { }); } - const response = await safeFetch(settings.umamiScriptUrl, { - next: { revalidate: 3600 }, - }); + const response = await safeFetch( + settings.umamiScriptUrl, + { + next: { revalidate: 3600 }, + }, + // Operator-configured host — pin the connect-time IP so a low-TTL + // DNS record cannot rebind the fetch at an internal range between + // the input-time `isPublicUrl` accept and the socket connect. + { requirePublicHost: true }, + ); if (!response.ok) { return new NextResponse(NOOP_SCRIPT, { status: 200, diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 32e77efd5..e8f8e13af 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -74,16 +74,21 @@ export const POST = apiHandler(async (request: NextRequest) => { let lastResponseStatus = 404; for (const targetUrl of targetUrls) { - const upstream = await safeFetch(targetUrl, { - method: "POST", - headers: { - "content-type": - request.headers.get("content-type") || "application/json", - "user-agent": request.headers.get("user-agent") || "healthlog-proxy", + const upstream = await safeFetch( + targetUrl, + { + method: "POST", + headers: { + "content-type": + request.headers.get("content-type") || "application/json", + "user-agent": request.headers.get("user-agent") || "healthlog-proxy", + }, + body, + cache: "no-store", }, - body, - cache: "no-store", - }); + // Operator-configured Umami host — pin the connect-time IP. + { requirePublicHost: true }, + ); lastResponseStatus = upstream.status; diff --git a/src/app/api/user/avatar/__tests__/route.test.ts b/src/app/api/user/avatar/__tests__/route.test.ts index bbaf6d761..4f0b26841 100644 --- a/src/app/api/user/avatar/__tests__/route.test.ts +++ b/src/app/api/user/avatar/__tests__/route.test.ts @@ -146,6 +146,41 @@ describe("POST /api/user/avatar", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); + it("rejects an oversize chunked upload with no Content-Length at the stream level (413)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + // Build a multipart body as a streamed ReadableStream with NO + // Content-Length header — the shape a `Transfer-Encoding: chunked` + // upload takes. The bounded stream reader must trip before the body + // is fully buffered. + const boundary = "----healthlogtest"; + const head = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`, + ); + const tail = Buffer.from(`\r\n--${boundary}--\r\n`); + const oversize = Buffer.alloc(2 * 1024 * 1024 + 1, 0x41); + const parts = [head, oversize, tail]; + const stream = new ReadableStream({ + start(controller) { + for (const p of parts) { + controller.enqueue(new Uint8Array(p)); + } + controller.close(); + }, + }); + const req = new Request("http://localhost/api/user/avatar", { + method: "POST", + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + body: stream, + // Node's fetch requires `duplex: "half"` for a streamed body. + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const res = await post(req); + expect(res.status).toBe(413); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it("rejects an unsupported MIME with 415 (magic-byte sniff)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // Plain text content — no JPEG/PNG/WebP magic. diff --git a/src/app/api/user/avatar/route.ts b/src/app/api/user/avatar/route.ts index 1aca9de97..296a95017 100644 --- a/src/app/api/user/avatar/route.ts +++ b/src/app/api/user/avatar/route.ts @@ -62,6 +62,67 @@ interface UploadResponse { updatedAt: string; } +/** Thrown by `readBoundedBody` when the stream exceeds the byte cap. */ +class BodyTooLargeError extends Error { + constructor() { + super("Request body exceeds the configured byte cap"); + this.name = "BodyTooLargeError"; + } +} + +/** + * Read a request body stream into a single buffer while counting bytes, + * throwing `BodyTooLargeError` the moment the running total passes + * `maxBytes`. Bounds the work at the stream level so a chunked / + * unbounded upload cannot park past the cap even when no `Content-Length` + * header is present. The captured bytes are retained (capped at + * `maxBytes`) so the caller can reconstruct `FormData` from them — this + * reads the body exactly once, with no clone and no second parser racing + * the same body, so a cap trip frees the allocation immediately instead + * of leaving a native parse buffering the rest of an oversized upload. + */ +async function readBoundedBody( + body: ReadableStream | null, + maxBytes: number, +): Promise> { + if (!body) return new Uint8Array(0); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let retained = 0; + let overflow = false; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (overflow) continue; + if (retained + value.byteLength > maxBytes) { + // Past the cap. Drop everything retained so far and stop + // retaining further chunks — memory stays bounded at the cap. + // Keep draining the stream to its natural close rather than + // cancelling it: an abrupt cancel mid-transfer races the + // underlying (undici) producer, which then enqueues into a + // closed controller and surfaces as an unhandled rejection. + chunks.length = 0; + retained = 0; + overflow = true; + continue; + } + chunks.push(value); + retained += value.byteLength; + } + } finally { + reader.releaseLock(); + } + if (overflow) throw new BodyTooLargeError(); + const out = new Uint8Array(retained); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + export const POST = apiHandler(async (request: Request) => { const { user } = await requireAuth(); @@ -94,10 +155,31 @@ export const POST = apiHandler(async (request: Request) => { ); } + // Stream-level cap. The Content-Length pre-flight above only fires when + // the header is present; a `Transfer-Encoding: chunked` upload (or any + // client that omits the header) skips it, and `request.formData()` + // would then buffer the whole stream before the post-parse `file.size` + // check can run. Read the body ONCE through a bounded reader that + // aborts on the first byte past AVATAR_MAX_BYTES, then reconstruct the + // FormData from the captured bytes. A single read means the cap trip + // frees the allocation immediately — no clone, no tee, and no native + // parse left buffering the rest of an oversized body. The post-parse + // `file.size` check stays in place as defence-in-depth. let formData: FormData; try { - formData = await request.formData(); + const bytes = await readBoundedBody(request.body, AVATAR_MAX_BYTES); + formData = await new Response(new Blob([bytes]), { + headers: { "content-type": request.headers.get("content-type") ?? "" }, + }).formData(); } catch (err) { + // Surface the most useful failure: a cap overflow always wins. + if (err instanceof BodyTooLargeError) { + annotate({ + action: { name: "user.avatar.upload.rejected" }, + meta: { reason: "stream_size_exceeded" }, + }); + return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413); + } return apiError( `Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`, 400, diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 56bdf5ccf..c62d4fd34 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -1,42 +1,31 @@ "use client"; /** - * v1.5.5 D-3 §6 + §9 — medication detail page. + * v1.5.6 G-1 — medication detail page as a pure history surface. * - * Replaces the v1.5.4 "form retirement" gap that left every action - * (Pausieren / Beenden / Notifications / API tokens / Phasen / CSV - * import / Verlauf löschen / Medikament löschen) reachable only - * through the list-page kebab — which the kebab didn't actually wire - * up. The detail page surfaces every restored action in section - * order; one phone-scroll reaches every one of them. + * Supersedes the v1.5.5 composition that stacked every setting inline. + * The page now reads as a calm "Vergangenheit" view: back link → slim + * header (name / dose / status + a two-option Bearbeiten dropdown) → + * STATIC cadence line → GLP-1 dose-ladder read-only visualisation → + * intake-history table. Every setting — Notifications, API tokens, + * Phasen, CSV import, grace and the destructive zone — moves into the + * ``, reached from the dropdown's "Erweiterte + * Einstellungen" item. "Plan bearbeiten" opens the wizard at Step 1. * - * Recurring variant: 8 sections (header → today's dose → cadence → - * dose ladder / phases → intake history preview → notifications → - * settings → destructive zone). - * - * One-shot variant: 5 sections (header → today's-dose-or-logged → - * cadence static line → intake history single row → destructive - * zone). Per D-3 §6 the wizard handles cadence edit via the header - * pencil so the cadence row hides its own edit affordance. - * - * Paused variant: same as the recurring/one-shot layout; the header - * status pill flips and the today's-dose card disables both buttons - * with a muted helper. Settings and destructive zone keep rendering. + * One-shot variant drops the dose-ladder section and renders the + * static `Einmalig am …` line in place of the cadence summary. * * Reads: * * - `medicationDetail(id)` for the medication snapshot. - * - `medicationIntakeList(id, …)` for the last-14-grouped preview - * (the v2 list owns its own paging). - * - `notificationsStatus()` + `authMe()` on the notifications - * section. - * - `medicationTitration(id)` on the GLP-1 dose ladder. + * - `medicationIntakeList(id, …)` for `intakeCount`, which the + * sheet's destructive zone needs. * - * Mutations all cascade through `medicationDependentKeys` (which now + * Mutations all cascade through `medicationDependentKeys` (which * includes the `compliance-chart-inline` prefix per D-3 §10). */ -import { use, useEffect, useState } from "react"; +import { use, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; @@ -49,16 +38,13 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; import { TitrationSection } from "@/components/medications/TitrationSection"; import { MedicationDetailHeader } from "@/components/medications/medication-detail-header"; -import { TodaysDoseCard } from "@/components/medications/todays-dose-card"; import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row"; import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview"; -import { NotificationsSection } from "@/components/medications/sections/notifications-section"; -import { SettingsSection } from "@/components/medications/sections/settings-section"; -import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section"; +import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet"; +import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet"; import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog"; import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; @@ -135,16 +121,6 @@ function snapshotToWizardPayload( }; } -function isToday(iso: string): boolean { - const d = new Date(iso); - const today = new Date(); - return ( - d.getFullYear() === today.getFullYear() && - d.getMonth() === today.getMonth() && - d.getDate() === today.getDate() - ); -} - export default function MedicationDetailPage({ params, }: { @@ -157,9 +133,8 @@ export default function MedicationDetailPage({ const formatters = useFormatters(); const [wizardOpen, setWizardOpen] = useState(false); - const [wizardIntent, setWizardIntent] = useState< - "cadence" | "name" | undefined - >(undefined); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [phaseSheetOpen, setPhaseSheetOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); useEffect(() => { @@ -201,6 +176,14 @@ export default function MedicationDetailPage({ enabled: isAuthenticated && !!medication, }); + // v1.5.6 F-1 M-2 — memoise the wizard payload so the header dropdown + // + wizard mount share one object identity per medication snapshot + // instead of recomputing it on every render. + const wizardPayload = useMemo( + () => (medication ? snapshotToWizardPayload(medication) : null), + [medication], + ); + if (authLoading || isLoading) { return (
- (e.takenAt && isToday(e.takenAt)) || - (e.skipped && isToday(e.scheduledFor)), - ); const intakeCount = intakeList?.meta.total ?? 0; const primaryGrace = medication.schedules[0]?.reminderGraceMinutes ?? null; + // `wizardPayload` is non-null here — the early returns above bail + // before this point whenever `medication` is undefined. + const payload = wizardPayload as MedicationPayload; - function openWizardWithIntent(intent: "cadence" | "name") { - setWizardIntent(intent); - setWizardOpen(true); - } - - function closeWizard() { - setWizardOpen(false); - setWizardIntent(undefined); + // v1.5.6 G-1 §5 — sibling-swap: close the advanced sheet first, then + // open the phase sheet so the two never stack (modal depth ≤ 2). + function openPhaseSheet() { + setAdvancedOpen(false); + setPhaseSheetOpen(true); } return ( @@ -279,30 +257,21 @@ export default function MedicationDetailPage({ - {/* §9.1 — Header band */} + {/* G-1 §3.2 — Header band. The Bearbeiten dropdown owns the + wizard ("Plan bearbeiten") + the advanced sheet ("Erweiterte + Einstellungen"); the page owns both open-states. */} openWizardWithIntent("name")} + onEditPlan={() => setWizardOpen(true)} + onOpenAdvanced={() => setAdvancedOpen(true)} /> - {/* §9.2 — Today's dose card */} - - - {/* §9.3 — Cadence summary. One-shot: static line; the - `hideEdit` flag suppresses the row's own edit affordance - because the header pencil routes the wizard. */} + {/* G-1 §3.3 — Static cadence line. One-shot collapses to the + `Einmalig am …` card; recurring renders the summary row with + `hideEdit` (cadence editing lives in the wizard). */} {oneShot ? ( ) : ( openWizardWithIntent("cadence")} + medication={payload} + hideEdit + onEdit={() => {}} /> )} - {/* §9.4 — Dose ladder / Phasen (GLP-1 only, recurring path - only — one-shot variant drops this) */} + {/* G-1 §3.4 — Dose ladder / Phasen visualisation (read-only, + GLP-1 recurring only). The editor lives in the sheet. */} {!oneShot && medication.treatmentClass === "GLP1" && ( <> )} - {/* §9.5 — Intake history preview */} + {/* G-1 §3.5 — Intake history table (primary surface). */} - {/* §9.6 + §9.7 — Notifications + Settings: recurring path only. */} - {!oneShot && ( - <> - - - - - )} - - {/* §9.8 — Verwaltung & Gefahrenzone */} - - {/* Wizard mount — edit-only on the detail page. Intent flips - between "name" (header pencil → Step 1) and "cadence" - (cadence row pencil → Step 5). Cancel returns to the detail - page (the success branch refreshes the medication detail - via the `medicationDependentKeys` cascade). */} + {/* G-1 §5 — phase sheet mounted as a sibling of the advanced + sheet so they never stack (GLP-1 + course window only). */} + {!oneShot && + medication.treatmentClass === "GLP1" && + Boolean(medication.startsOn) && + Boolean(medication.endsOn) && ( + + )} + + {/* Wizard mount — edit-only, lands on Step 1 (no landing + intent). Cancel + success both close via the cascade. */} { - if (!open) closeWizard(); - }} + onOpenChange={setWizardOpen} mode="edit" - initial={snapshotToWizardPayload(medication)} - landingIntent={wizardIntent} - onSuccess={() => closeWizard()} + initial={payload} + onSuccess={() => setWizardOpen(false)} />
); diff --git a/src/app/medications/__tests__/detail-page-history-surface.test.ts b/src/app/medications/__tests__/detail-page-history-surface.test.ts new file mode 100644 index 000000000..f8c17e1b4 --- /dev/null +++ b/src/app/medications/__tests__/detail-page-history-surface.test.ts @@ -0,0 +1,48 @@ +/** + * v1.5.6 G-1 §3 — the `/medications/[id]` detail page is a pure + * history surface. + * + * Source-level guards (the page is a client component with `use()` + * + hooks, so a full render is heavier than the contract warrants): + * - no `TodaysDoseCard` import or render (removed from the detail + * page; it lives only on the list page); + * - no `isToday` / `todayEvent` derivations (dead once the card is + * gone); + * - no `landingIntent` / `wizardIntent` / `openWizardWithIntent` + * plumbing (the header dropdown is the only wizard entry, landing + * on Step 1); + * - the page hosts the `AdvancedSettingsSheet` and renders the + * cadence row with `hideEdit`. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + join(process.cwd(), "src/app/medications/[id]/page.tsx"), + "utf8", +); + +describe("medication detail page is a pure history surface (G-1 §3)", () => { + it("does not import or render TodaysDoseCard", () => { + expect(source).not.toContain("TodaysDoseCard"); + expect(source).not.toContain("todays-dose-card"); + }); + + it("drops the dead isToday / todayEvent derivations", () => { + expect(source).not.toContain("isToday"); + expect(source).not.toContain("todayEvent"); + }); + + it("drops the landingIntent plumbing", () => { + expect(source).not.toContain("landingIntent"); + expect(source).not.toContain("wizardIntent"); + expect(source).not.toContain("openWizardWithIntent"); + }); + + it("hosts the AdvancedSettingsSheet and the static cadence row", () => { + expect(source).toContain("AdvancedSettingsSheet"); + expect(source).toContain("hideEdit"); + }); +}); diff --git a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx new file mode 100644 index 000000000..063683824 --- /dev/null +++ b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx @@ -0,0 +1,137 @@ +/** + * v1.5.6 G-1 §5 — AdvancedSettingsSheet composition contract. + * + * The sheet hosts the three moved sections in routine → rare → + * destructive order: Notifications → Settings → destructive zone. + * `` wraps Radix portals that `renderToStaticMarkup` + * does not materialise, so we mock it to a passthrough that renders + * children only when `open`, and mock the three sections to ordered + * markers. The test pins: + * - the three sections render in the documented order; + * - `onRequestPhaseSheet` threads through to ``; + * - the destructive zone's `onAfterAction` closes the sheet; + * - the sheet renders nothing when `open` is false. + */ + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ + open, + children, + title, + }: { + open: boolean; + children: React.ReactNode; + title: React.ReactNode; + }) => + open ? ( +
+
{title}
+ {children} +
+ ) : null, +})); + +vi.mock( + "@/components/medications/sections/notifications-section", + () => ({ + NotificationsSection: () =>
, + }), +); + +vi.mock("@/components/medications/sections/settings-section", () => ({ + SettingsSection: ({ + onRequestPhaseSheet, + }: { + onRequestPhaseSheet?: () => void; + }) => ( +
+ ), +})); + +vi.mock( + "@/components/medications/sections/destructive-zone-section", + () => ({ + DestructiveZoneSection: ({ + onAfterAction, + }: { + onAfterAction?: () => void; + }) => ( + + ), +})); + import { MedicationDetailHeader } from "@/components/medications/medication-detail-header"; function render(props: { @@ -40,7 +69,8 @@ function render(props: { dose={props.dose ?? "5 mg"} active={props.active ?? true} endsOn={props.endsOn} - onEdit={() => {}} + onEditPlan={() => {}} + onOpenAdvanced={() => {}} />, ); } @@ -79,6 +109,22 @@ describe("MedicationDetailHeader (D-3 §9.1)", () => { expect(headingIndex).toBeLessThan(buttonIndex); }); + it("renders the two-option Bearbeiten picker (G-1 §4)", () => { + const html = render({ active: true }); + expect(html).toContain("medications.detail.edit.planOption"); + expect(html).toContain("medications.detail.edit.advancedOption"); + expect(html).toContain('data-slot="medication-detail-edit-plan"'); + expect(html).toContain('data-slot="medication-detail-edit-advanced"'); + }); + + it("orders Plan bearbeiten above Erweiterte Einstellungen", () => { + const html = render({ active: true }); + const plan = html.indexOf("medications.detail.edit.planOption"); + const advanced = html.indexOf("medications.detail.edit.advancedOption"); + expect(plan).toBeGreaterThan(-1); + expect(advanced).toBeGreaterThan(plan); + }); + it("status pill text always renders (no colour-only state)", () => { // Regardless of theme support the label MUST be in the DOM so a // screen reader announces "Aktiv" even when the dot's hue is diff --git a/src/components/medications/advanced-settings-sheet.tsx b/src/components/medications/advanced-settings-sheet.tsx new file mode 100644 index 000000000..9877a559b --- /dev/null +++ b/src/components/medications/advanced-settings-sheet.tsx @@ -0,0 +1,101 @@ +"use client"; + +/** + * v1.5.6 G-1 §5 — Advanced settings sheet. + * + * Hosts the three settings sections the detail page no longer renders + * inline: Notifications, Settings (API tokens / CSV stub / Phasen / + * grace) and the destructive zone, stacked top → bottom in + * routine → rare → destructive order inside a ``. + * + * The Phasen button is sibling-swapped, not nested: when the user + * opens it, `onRequestPhaseSheet` closes this sheet first and lets the + * parent open the phase sheet so the modal stack never exceeds two + * (G-1 §5 / D-3 §10 invariant 7). Each moved section saves itself, so + * the footer slot stays empty. On a non-navigating destructive success + * the sheet closes; the Tier 3b delete already navigates to + * `/medications`. + */ + +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { NotificationsSection } from "@/components/medications/sections/notifications-section"; +import { SettingsSection } from "@/components/medications/sections/settings-section"; +import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section"; +import { useTranslations } from "@/lib/i18n/context"; + +export interface AdvancedSettingsSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + medicationId: string; + medicationName: string; + treatmentClass?: string; + active: boolean; + startsOn?: string | null; + endsOn?: string | null; + notificationsEnabled: boolean; + reminderGraceMinutes: number | null; + intakeCount: number; + /** + * Sibling-swap the phase sheet: the parent closes this sheet and + * opens `` so the two never stack (G-1 §5). + */ + onRequestPhaseSheet?: () => void; +} + +export function AdvancedSettingsSheet({ + open, + onOpenChange, + medicationId, + medicationName, + treatmentClass, + active, + startsOn, + endsOn, + notificationsEnabled, + reminderGraceMinutes, + intakeCount, + onRequestPhaseSheet, +}: AdvancedSettingsSheetProps) { + const { t } = useTranslations(); + + return ( + +
+ {/* routine */} + + + {/* rare */} + + + {/* destructive */} + onOpenChange(false)} + /> +
+
+ ); +} diff --git a/src/components/medications/cadence-summary-row.tsx b/src/components/medications/cadence-summary-row.tsx index 2e3471772..5cc40cd80 100644 --- a/src/components/medications/cadence-summary-row.tsx +++ b/src/components/medications/cadence-summary-row.tsx @@ -6,7 +6,8 @@ * Renders the medication's cadence as a plain-language line (e.g. * "Alle 12 h nach letzter Einnahme", "Alle 2 Wochen, mittwochs", * "Monatlich am 15.") with an optional course-window sub-line, and an - * edit pencil that opens the wizard with `landingIntent: "cadence"`. + * optional edit pencil whose `onEdit` the caller wires (suppressed via + * `hideEdit` on the read-only detail surface). * For one-shot medications the line collapses to * `Einmalig am DD.MM.` and the edit affordance is suppressed (the * header pencil owns the wizard route per D-3 §6 one-shot variant). diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 096b98b05..da3854463 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -7,9 +7,9 @@ import { AlertCircle, AlertTriangle, Check, + ChevronRight, CircleCheck, Flame, - History, Loader2, MoreVertical, Pencil, @@ -337,7 +337,7 @@ export function Glp1MedicationCard({ aria-label={t("medications.openDetailPage")} > - +
- + + + + + + + {t("medications.detail.edit.planOption")} + + + {t("medications.detail.edit.advancedOption")} + + + ); } diff --git a/src/components/medications/sections/__tests__/settings-section-phase-swap.test.tsx b/src/components/medications/sections/__tests__/settings-section-phase-swap.test.tsx new file mode 100644 index 000000000..49aeb9062 --- /dev/null +++ b/src/components/medications/sections/__tests__/settings-section-phase-swap.test.tsx @@ -0,0 +1,71 @@ +/** + * v1.5.6 G-1 §5 — phase sibling-swap wiring on ``. + * + * When the hosting `` passes + * `onRequestPhaseSheet`, the section must NOT mount its own + * `` — the parent orchestrates the swap (close the + * advanced sheet, then open the phase sheet) so the two never stack. + * Without the callback the section keeps owning its own phase sheet, + * preserving the standalone-surface fallback. + * + * `` is mocked to a marker so its presence / + * absence is observable in static markup regardless of open-state. + */ + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/components/medications/sections/api-tokens-row", () => ({ + ApiTokensRow: () =>
, +})); + +vi.mock("@/components/medications/sections/phase-config-sheet", () => ({ + PhaseConfigSheet: () =>
, +})); + +import { SettingsSection } from "@/components/medications/sections/settings-section"; + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); +} + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + + {node} + , + ); +} + +// GLP-1 + a set course window so the Phasen row + button mount. +const glp1Props = { + medicationId: "med-1", + medicationName: "Test Drug", + treatmentClass: "GLP1", + startsOn: "2026-01-01", + endsOn: "2026-06-01", + reminderGraceMinutes: 30, +}; + +describe("SettingsSection phase sibling-swap (G-1 §5)", () => { + it("does NOT self-mount the phase sheet when onRequestPhaseSheet is provided", () => { + const html = render( + {}} />, + ); + expect(html).toContain("medication-detail-phase-management-button"); + expect(html).not.toContain("mock-phase-config-sheet"); + }); + + it("self-mounts the phase sheet on the standalone surface (no callback)", () => { + const html = render(); + expect(html).toContain("medication-detail-phase-management-button"); + expect(html).toContain("mock-phase-config-sheet"); + }); +}); diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index 984dd336f..2a6d59205 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -55,6 +55,13 @@ export interface DestructiveZoneSectionProps { medicationName: string; active: boolean; intakeCount: number; + /** + * v1.5.6 G-1 §5 — fires after a non-navigating destructive success + * (Tier 1 pause/resume, Tier 2 end, Tier 3a purge) so a hosting + * sheet can close itself. Tier 3b delete navigates instead and does + * not call this. Absent on the standalone surface. + */ + onAfterAction?: () => void; } const PAUSE_SWITCH_ID = "medication-detail-pause-switch"; @@ -66,6 +73,7 @@ export function DestructiveZoneSection({ medicationName, active, intakeCount, + onAfterAction, }: DestructiveZoneSectionProps) { const { t } = useTranslations(); const router = useRouter(); @@ -107,6 +115,7 @@ export function DestructiveZoneSection({ ? t("medications.detail.zone.pause.pausedToast") : t("medications.detail.zone.pause.resumedToast"), ); + onAfterAction?.(); } catch { setPaused(previous); toast.error(t("medications.detail.zone.pause.failed")); @@ -132,6 +141,7 @@ export function DestructiveZoneSection({ await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.zone.end.toast")); setEndDialogOpen(false); + onAfterAction?.(); } catch { toast.error(t("medications.detail.zone.end.failed")); } finally { @@ -154,6 +164,7 @@ export function DestructiveZoneSection({ await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.zone.purge.toast")); setPurgeDialogOpen(false); + onAfterAction?.(); } catch { toast.error(t("medications.detail.zone.purge.failed")); } finally { diff --git a/src/components/medications/sections/intake-history-preview.tsx b/src/components/medications/sections/intake-history-preview.tsx index 16edf87e6..6bbf13dad 100644 --- a/src/components/medications/sections/intake-history-preview.tsx +++ b/src/components/medications/sections/intake-history-preview.tsx @@ -40,7 +40,10 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; -import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2"; +import { + IntakeHistoryListV2, + type IntakeEvent, +} from "@/components/medications/intake-history-list-v2"; import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { useTranslations } from "@/lib/i18n/context"; @@ -61,7 +64,7 @@ export function IntakeHistoryPreview({ const queryClient = useQueryClient(); const [selected, setSelected] = useState>(new Set()); - const [editingId, setEditingId] = useState(null); + const [editingEvent, setEditingEvent] = useState(null); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false); const [bulkBusy, setBulkBusy] = useState(false); @@ -206,7 +209,7 @@ export function IntakeHistoryPreview({ - onImportOpenChange(false)} - /> + {importOpen && ( + onImportOpenChange(false)} + /> + )} setEditingId(null)} + onClose={() => setEditingEvent(null)} /> `) instead of the + * self-managed `phaseSheetOpen` state, so the two sheets never + * stack. Absent on the standalone surface, where the section owns + * its own phase sheet. + */ + onRequestPhaseSheet?: () => void; } export function SettingsSection({ @@ -49,6 +58,7 @@ export function SettingsSection({ startsOn, endsOn, reminderGraceMinutes, + onRequestPhaseSheet, }: SettingsSectionProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -136,7 +146,11 @@ export function SettingsSection({
- {showPhases && ( + {showPhases && !onRequestPhaseSheet && ( void; } @@ -113,14 +105,13 @@ const STEP_ICONS = { type StepNumber = keyof typeof STEP_ICONS; export function MedicationWizardDialog(props: MedicationWizardDialogProps) { - // Key the inner state container on (open + mode + medication id + - // landingIntent) so every open gets a fresh state tree AND every - // change of intent re-runs the initial-step decision. React unmounts - // the inner component when the key changes, which sidesteps the - // "setState in useEffect" anti-pattern that resetting on open via an - // effect would otherwise trigger. The outer shell stays mounted so - // the dialog's close animation runs cleanly. - const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}:${props.landingIntent ?? "default"}`; + // Key the inner state container on (open + mode + medication id) so + // every open gets a fresh state tree. React unmounts the inner + // component when the key changes, which sidesteps the "setState in + // useEffect" anti-pattern that resetting on open via an effect would + // otherwise trigger. The outer shell stays mounted so the dialog's + // close animation runs cleanly. + const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}`; return ; } @@ -129,7 +120,6 @@ function WizardDialogShell({ onOpenChange, mode, initial, - landingIntent, onSuccess, }: MedicationWizardDialogProps) { const { t, locale } = useTranslations(); @@ -144,7 +134,7 @@ function WizardDialogShell({ const [step, setStep] = useState(() => { if (mode === "edit" && initial) { const hydrated = hydrateWizardPayload(initial); - return landingStepForEdit(hydrated, landingIntent) as StepNumber; + return landingStepForEdit(hydrated) as StepNumber; } return 1; }); diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 0124acb60..caa0854aa 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -722,63 +722,6 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => { expect(landingStepForEdit(single)).toBe(1); }); - // v1.5.5 C-E2-2 — `landingStepForEdit` gains an `intent` arg so the - // detail-page cadence-summary row can drop the user directly on - // Step 5 instead of bouncing them through Step 1 first. One-shot - // payloads fall back to Step 8 because the cadence picker is not on - // the one-shot path. - it("lands on Step 5 for the cadence intent", () => { - const payload = hydrateWizardPayload({ - id: "m_cadence", - name: "Test", - dose: "10", - category: "OTHER", - treatmentClass: "GENERIC", - notificationsEnabled: true, - startsOn: new Date(Date.UTC(2026, 4, 28)), - endsOn: null, - oneShot: false, - schedules: [ - { - id: "sch_a", - windowStart: "08:00", - windowEnd: "09:00", - timesOfDay: ["08:00"], - rrule: "FREQ=DAILY", - rollingIntervalDays: null, - }, - ], - }); - expect(landingStepForEdit(payload, "cadence")).toBe(5); - expect(landingStepForEdit(payload, "name")).toBe(1); - expect(landingStepForEdit(payload, "summary")).toBe(8); - }); - - it("falls back to Step 8 on cadence intent for a one-shot medication", () => { - const payload = hydrateWizardPayload({ - id: "m_oneshot", - name: "Test", - dose: "10", - category: "OTHER", - treatmentClass: "GENERIC", - notificationsEnabled: true, - startsOn: new Date(Date.UTC(2026, 4, 28)), - endsOn: null, - oneShot: true, - schedules: [ - { - id: "sch_a", - windowStart: "08:00", - windowEnd: "09:00", - timesOfDay: ["08:00"], - rrule: null, - rollingIntervalDays: null, - }, - ], - }); - expect(landingStepForEdit(payload, "cadence")).toBe(8); - }); - it("removeSchedule refuses when schedules.length === 1", () => { const payload = emptyWizardPayload(); expect(payload.schedules).toHaveLength(1); diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index f6d0cafc1..f089e9338 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -879,36 +879,12 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload } /** - * Decision the dialog uses to pick the landing step on edit-hydrate. - * - * Three call-site intents: - * - * - `"name"` (default header pencil): land on Step 1 so the user - * edits the medication's name, dose, treatment-class as a single - * top-down walk. Multi-schedule medications still jump to the - * summary list because the muscle memory there is "see every - * parallel schedule first". - * - `"cadence"`: land directly on Step 5 (the cadence picker). The - * v1.5.5 detail page's cadence-summary row uses this so a tap on - * the row's edit pencil drops the user one screen away from the - * cadence they wanted to change, not two screens away after the - * intro + summary. - * - undefined: legacy behaviour — Step 1 for single-schedule, Step 8 - * for multi-schedule. - * - * One-shot medications collapse Step 5 out of the path, so a - * `"cadence"` intent on a one-shot payload falls back to Step 8 - * (the summary, where the user can flip the one-shot toggle). + * Decision the dialog uses to pick the landing step on edit-hydrate: + * Step 1 for a single-schedule medication (a straight top-down walk), + * Step 8 (the summary list) for a multi-schedule one, where the muscle + * memory is "see every parallel schedule first". */ -export function landingStepForEdit( - payload: WizardPayload, - intent?: "cadence" | "summary" | "name", -): number { - if (intent === "cadence") { - return payload.mode === "oneShot" ? 8 : 5; - } - if (intent === "summary") return 8; - if (intent === "name") return 1; +export function landingStepForEdit(payload: WizardPayload): number { return payload.schedules.length > 1 ? 8 : 1; } diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index f05a8d615..0309abcdd 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { safeFetch } from "@/lib/safe-fetch"; import type { AIProvider, CompletionParams, CompletionResult } from "./types"; import { getCachedCodexSlug, @@ -283,7 +284,7 @@ export class CodexClient implements AIProvider { ): Promise { const sessionId = randomUUID(); const threadId = randomUUID(); - return fetch(CODEX_ENDPOINT, { + return safeFetch(CODEX_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", @@ -320,7 +321,10 @@ export class CodexClient implements AIProvider { // Required field; empty array when not asking for reasoning. include: [], }), - }); + // SSE streaming completion — match the 60 s budget the other AI + // clients use so a long generation is not clipped by the 15 s + // default while still bounding a tar-pit upstream. + }, { timeoutMs: 60_000 }); } /** diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts index 33eb67473..3f4f6e1b7 100644 --- a/src/lib/ai/codex-oauth.ts +++ b/src/lib/ai/codex-oauth.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { encrypt, decrypt } from "@/lib/crypto"; +import { safeFetch } from "@/lib/safe-fetch"; /** * Codex / ChatGPT-OAuth flow — implemented against `docs/codex-protocol-spec.md`, @@ -173,7 +174,7 @@ export interface DeviceCodeStart { } export async function requestDeviceCode(): Promise { - const res = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { + const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: getCodexClientId() }), @@ -214,7 +215,7 @@ export async function pollDeviceCode(params: { deviceAuthId: string; userCode: string; }): Promise { - const res = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, { + const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -240,7 +241,7 @@ export async function pollDeviceCode(params: { // Standard PKCE exchange against the OAuth token endpoint, with the // device-auth callback URI Hydra associates with this authorization // code internally. - const tokenRes = await fetch(`${ISSUER}/oauth/token`, { + const tokenRes = await safeFetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -276,7 +277,7 @@ export async function pollDeviceCode(params: { export async function refreshDeviceTokens( refreshToken: string, ): Promise { - const res = await fetch(`${ISSUER}/oauth/token`, { + const res = await safeFetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index cf04e631d..b8aa93335 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -100,6 +100,13 @@ import { drainPerSampleCumulative, DRAIN_CUMULATIVE_CUTOFF_HOURS, } from "@/lib/measurements/drain-per-sample-cumulative"; +import { + STEP_CONSOLIDATION_QUEUE, + STEP_CONSOLIDATION_CONCURRENCY, + runStepConsolidationForUser, + enqueueBootTimeStepConsolidation, + type StepConsolidationPayload, +} from "@/lib/jobs/step-consolidation"; import { expireStaleInUseItems } from "@/lib/medications/inventory/service"; import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret"; import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe"; @@ -1868,6 +1875,14 @@ export async function startReminderWorker() { // rollup tier. Discovery enqueues one job per user with intake // events but no rollup coverage; idempotent across reboots. MEDICATION_COMPLIANCE_BACKFILL_QUEUE, + // v1.5.6 — boot-time legacy step consolidation. Discovery enqueues + // one job per user still holding live pre-v1.5.0 granular step + // rows; the per-user pass collapses them into one daily total and + // soft-deletes the originals. Idempotent across reboots — a + // consolidated user drops off the discovery list. The queue MUST be + // registered here or pg-boss never provisions it and the boot + // enqueue silently never drains. + STEP_CONSOLIDATION_QUEUE, // v1.4.37 W7c — explicit createQueue is required before the // nightly schedule below registers (pg-boss v12 contract). Without // this entry the drain schedule silently no-ops and the @@ -2173,6 +2188,27 @@ export async function startReminderWorker() { }, ); + // v1.5.6 — legacy step consolidation worker. The boot enqueue helper + // below sends one job per user still holding live pre-v1.5.0 granular + // step rows; this handler collapses them into one daily-total row per + // calendar day and soft-deletes the originals. Serial concurrency so + // the populator never crowds the dashboard request pool. + await boss.work( + STEP_CONSOLIDATION_QUEUE, + { localConcurrency: STEP_CONSOLIDATION_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + const { daysConsolidated, legacyRowsSoftDeleted } = + await runStepConsolidationForUser(userId); + workerLog( + "info", + `[step-consolidation] user=${userId} days=${daysConsolidated} legacyRowsSoftDeleted=${legacyRowsSoftDeleted}`, + ); + } + }, + ); + // v1.4.39 W-MOOD — mood-rollup per-bucket worker. Folds the // WEEK / MONTH / YEAR buckets that the mood-entry write hooks // enqueue; the DAY bucket runs synchronously in the hook itself. @@ -2320,6 +2356,36 @@ export async function startReminderWorker() { ); } + // v1.5.6 — fire-and-forget boot discovery for the legacy step + // consolidation pass. Finds every user still holding live pre-v1.5.0 + // granular step rows and enqueues one consolidation job per account. + // Idempotent across reboots: consolidated legacy rows are + // soft-deleted, so the `deleted_at IS NULL` discovery predicate drops + // them and the user falls off the list. Errors are returned through + // the helper's result value — the worker boot never fails because of + // a consolidation miss. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeStepConsolidation(); + if (error) { + workerLog( + "error", + `[step-consolidation] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[step-consolidation] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[step-consolidation] boot discovery threw an unexpected error", + err, + ); + } + // v1.4.39 W-MOOD — fire-and-forget boot discovery for the mood // rollup tier. Mirrors the v1.4.35.1 measurement-rollup pattern: // one job per user with mood entries but no rollup coverage. diff --git a/src/lib/jobs/step-consolidation.ts b/src/lib/jobs/step-consolidation.ts new file mode 100644 index 000000000..f447169b9 --- /dev/null +++ b/src/lib/jobs/step-consolidation.ts @@ -0,0 +1,137 @@ +/** + * v1.5.6 — pg-boss queue + boot-time converging backfill for the + * legacy step consolidation pass. Modelled on the + * `rollup-full-backfill` boot-time pattern: a discovery query enqueues + * one job per user still holding live legacy step rows, the per-user + * handler runs `consolidateLegacySteps`, and the pass is idempotent + * across reboots (consolidated rows are soft-deleted so they drop off + * the discovery list). + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; + * an unregistered queue silently never drains. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { consolidateLegacySteps } from "@/lib/measurements/consolidate-legacy-steps"; + +export const STEP_CONSOLIDATION_QUEUE = "step-consolidation"; + +/** + * Serial concurrency — the populator walks every legacy step row for a + * user and writes in a transaction per day; concurrency-1 keeps it from + * crowding the request pool, matching `ROLLUP_FULL_BACKFILL_CONCURRENCY`. + */ +export const STEP_CONSOLIDATION_CONCURRENCY = 1; + +export interface StepConsolidationPayload { + userId: string; + enqueuedAt: string; +} + +/** + * Per-user queue handler. Runs the consolidation for one account and + * returns the summary totals so the worker can log them. + */ +export async function runStepConsolidationForUser( + userId: string, +): Promise<{ daysConsolidated: number; legacyRowsSoftDeleted: number }> { + const summary = await consolidateLegacySteps(prisma, { + userId, + log: () => { + // Silent inside the queue handler — the worker logs the totals. + }, + }); + // Stable wide-event action name (`..`). No-ops + // outside a request/job event context. + annotate({ + action: { + name: "measurement.step.consolidate", + details: { + days: summary.totals.daysConsolidated, + legacy_rows_soft_deleted: summary.totals.legacyRowsSoftDeleted, + days_folded_into_existing: summary.totals.daysFoldedIntoExisting, + }, + }, + }); + return { + daysConsolidated: summary.totals.daysConsolidated, + legacyRowsSoftDeleted: summary.totals.legacyRowsSoftDeleted, + }; +} + +/** + * Boot-time discovery. Finds every user with at least one LIVE legacy + * step row (an `ACTIVITY_STEPS` row whose `externalId` is NULL or does + * NOT start with the daily-stats prefix, and that is not tombstoned) + * and enqueues one consolidation job per account. + * + * Idempotent across reboots: once a user's legacy rows are + * soft-deleted, the `deleted_at IS NULL` predicate drops them from the + * discovery set and the user falls off the list. pg-boss `singletonKey` + * coalesces duplicate sends within the queue so a fast restart while a + * job is queued doesn't double up. + * + * Best-effort: errors are returned through the result value so the + * worker boot never fails because of a consolidation miss. + */ +export async function enqueueBootTimeStepConsolidation(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + // The daily-total externalId shape is `stats::`. A + // live row is a legacy candidate when its externalId does NOT match + // that prefix (NULL externalId — a manual/legacy row — also + // qualifies because `LIKE` against NULL is NULL/false). Splice-free: + // the prefix is a constant compile-time literal, not user input. + const users = await prisma.$queryRaw>` + SELECT DISTINCT m."user_id" AS id + FROM measurements m + WHERE m."type" = 'ACTIVITY_STEPS' + AND m."deleted_at" IS NULL + AND ( + m."external_id" IS NULL + OR m."external_id" NOT LIKE 'stats:HKQuantityTypeIdentifierStepCount:%' + ) + `; + + if (users.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const { id } of users) { + const payload: StepConsolidationPayload = { + userId: id, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(STEP_CONSOLIDATION_QUEUE, payload, { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `step-consolidation|${id}`, + }); + if (jobId) { + enqueued += 1; + } else { + skipped += 1; + } + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/logging/transports.ts b/src/lib/logging/transports.ts index 15a360f91..4bbddb090 100644 --- a/src/lib/logging/transports.ts +++ b/src/lib/logging/transports.ts @@ -72,7 +72,9 @@ async function flushLokiBuffer(): Promise { headers, body: JSON.stringify({ streams }), }, - { timeoutMs: 10_000 }, + // Operator-configured Loki endpoint — pin the connect-time IP + // against DNS rebinding. + { timeoutMs: 10_000, requirePublicHost: true }, ); } catch { // Events gehen verloren — akzeptabel fuer Logging diff --git a/src/lib/measurements/__tests__/consolidate-legacy-steps.test.ts b/src/lib/measurements/__tests__/consolidate-legacy-steps.test.ts new file mode 100644 index 000000000..4f3438d75 --- /dev/null +++ b/src/lib/measurements/__tests__/consolidate-legacy-steps.test.ts @@ -0,0 +1,80 @@ +/** + * v1.5.6 — unit coverage for the extractable bucketing + summation + * helpers behind the legacy step-consolidation pass. The DB-touching + * end-to-end contract lives in + * `tests/integration/consolidate-legacy-steps.test.ts`. + */ +import { describe, expect, it } from "vitest"; + +import { + bucketLegacyStepRows, + sumLegacyStepValues, + STEP_DAILY_STATS_PREFIX, +} from "../consolidate-legacy-steps"; +import type { PerSampleRow } from "../drain-per-sample-cumulative"; + +function row( + partial: Partial & { measuredAt: Date }, +): PerSampleRow { + return { + id: partial.id ?? "id-1", + type: partial.type ?? "ACTIVITY_STEPS", + value: partial.value ?? 100, + measuredAt: partial.measuredAt, + externalId: partial.externalId ?? null, + }; +} + +describe("bucketLegacyStepRows", () => { + it("groups rows by the user's calendar day", () => { + const tz = "Europe/Berlin"; + const rows = [ + row({ id: "a", measuredAt: new Date("2026-05-16T08:00:00.000Z") }), + row({ id: "b", measuredAt: new Date("2026-05-16T20:00:00.000Z") }), + row({ id: "c", measuredAt: new Date("2026-05-17T06:00:00.000Z") }), + ]; + const byDay = bucketLegacyStepRows(rows, tz); + expect(byDay.get("2026-05-16")).toHaveLength(2); + expect(byDay.get("2026-05-17")).toHaveLength(1); + }); + + it("skips rows already in the daily-stats shape", () => { + const tz = "Europe/Berlin"; + const rows = [ + row({ + id: "total", + externalId: `${STEP_DAILY_STATS_PREFIX}2026-05-16`, + measuredAt: new Date("2026-05-16T10:00:00.000Z"), + }), + row({ id: "legacy", measuredAt: new Date("2026-05-16T08:00:00.000Z") }), + ]; + const byDay = bucketLegacyStepRows(rows, tz); + expect(byDay.get("2026-05-16")).toHaveLength(1); + expect(byDay.get("2026-05-16")?.[0].id).toBe("legacy"); + }); + + it("buckets a late-evening UTC sample into the next local day", () => { + // 23:30 UTC on 2026-05-16 is 01:30 on 2026-05-17 in Berlin (UTC+2). + const byDay = bucketLegacyStepRows( + [row({ id: "late", measuredAt: new Date("2026-05-16T23:30:00.000Z") })], + "Europe/Berlin", + ); + expect(byDay.has("2026-05-17")).toBe(true); + expect(byDay.has("2026-05-16")).toBe(false); + }); +}); + +describe("sumLegacyStepValues", () => { + it("sums the values in a bucket", () => { + const rows = [ + row({ value: 1200, measuredAt: new Date("2026-05-16T08:00:00.000Z") }), + row({ value: 3400, measuredAt: new Date("2026-05-16T14:00:00.000Z") }), + row({ value: 800, measuredAt: new Date("2026-05-16T20:00:00.000Z") }), + ]; + expect(sumLegacyStepValues(rows)).toBe(5400); + }); + + it("returns 0 for an empty bucket", () => { + expect(sumLegacyStepValues([])).toBe(0); + }); +}); diff --git a/src/lib/measurements/consolidate-legacy-steps.ts b/src/lib/measurements/consolidate-legacy-steps.ts new file mode 100644 index 000000000..2241c3b6c --- /dev/null +++ b/src/lib/measurements/consolidate-legacy-steps.ts @@ -0,0 +1,350 @@ +/** + * v1.5.6 — consolidate pre-v1.5.0 legacy step rows into one daily-total + * row per user per calendar day. + * + * Pre-v1.5.0, HealthKit step samples landed at sampling granularity — + * hundreds-to-thousands of `ACTIVITY_STEPS` rows per day. Post-v1.5.0, + * iOS emits one overwrite-able daily-total row carrying the + * `stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD` externalId + * (see `src/app/api/measurements/batch/route.ts` stats-overwrite logic). + * This pass collapses the legacy granular rows into the same canonical + * daily shape so historical step data reads consistently. + * + * Per user × calendar day (anchored to `User.timezone`): + * 1. SELECT live (`deletedAt IS NULL`) `ACTIVITY_STEPS` rows whose + * `externalId` does NOT start with the daily-stats prefix. + * 2. Group into per-day buckets in the user's timezone. + * 3. SUM the legacy values for the day. + * 4. UPSERT the canonical daily-total row keyed by + * `stats:HKQuantityTypeIdentifierStepCount:`. If a daily + * total already exists for the day (post-v1.5.0 iOS already wrote + * one), the existing total is the source of truth — it is NOT + * overwritten and the legacy sum is NOT folded in (avoids double- + * counting: the post-v1.5.0 row already represents the true daily + * total HealthKit computed). The legacy granular rows are still + * soft-deleted so they stop polluting the per-sample read path. + * 5. SOFT-DELETE (set `deletedAt`) the legacy granular rows — they + * stay in the table as an audit/backup trail. Sampling-granularity + * loss is explicitly accepted. + * + * Idempotent: the discovery query (`enqueueBootTimeStepConsolidation`) + * matches only users still holding live legacy step rows, so a second + * run converges to zero work. Within a run, soft-deleted legacy rows + * are excluded from the scan, so a re-run after a partial pass never + * re-aggregates an already-consolidated day. + * + * Runs on pg-boss (`STEP_CONSOLIDATION_QUEUE`), not a CLI — the + * production standalone image strips `tsx`. Modelled on the + * `rollup-full-backfill` boot-time converging-backfill pattern. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { dailyStatsExternalId } from "./apple-health-mapping"; +import { + canonicalDailyTimestamp, + dayKeyForUserTz, + type PerSampleRow, +} from "./drain-per-sample-cumulative"; + +/** HealthLog measurement type that carries step counts. */ +const STEP_TYPE = "ACTIVITY_STEPS" as const; + +/** Canonical HealthKit identifier for the step type. */ +const STEP_HK_IDENTIFIER = "HKQuantityTypeIdentifierStepCount"; + +/** + * Daily-total externalId prefix. Legacy rows that do NOT start with + * this are candidates for consolidation; the daily-total row this pass + * mints carries the full `stats:HKQuantityTypeIdentifierStepCount:` + * externalId. + */ +export const STEP_DAILY_STATS_PREFIX = `stats:${STEP_HK_IDENTIFIER}:`; + +/** Per-(user, day) action summary. */ +export interface StepConsolidationBucket { + userId: string; + /** Calendar-day key in the user's timezone (`YYYY-MM-DD`). */ + dateKey: string; + /** Number of legacy granular rows folded for this day. */ + legacyRowCount: number; + /** SUM of the legacy granular values for the day. */ + legacySum: number; + /** Whether a `stats:` daily total already existed for this day. */ + hadExistingTotal: boolean; + /** ISO-8601 canonical timestamp (local-noon of the user's day). */ + canonicalTimestamp: string; + /** Resulting daily-total `externalId`. */ + externalId: string; +} + +export interface StepConsolidationSummary { + /** Did the run actually write to the DB (false for `dryRun`). */ + dryRun: boolean; + /** Per-user-day buckets the pass rewrote (or would rewrite). */ + buckets: StepConsolidationBucket[]; + totals: { + usersScanned: number; + daysConsolidated: number; + legacyRowsSoftDeleted: number; + dailyRowsUpserted: number; + /** Days skipped because a `stats:` total already existed. */ + daysFoldedIntoExisting: number; + /** + * Days skipped because the per-day mint hit a unique-constraint + * collision (P2002) and the transaction rolled back. Counted, logged, + * and stepped over so a single poison day can never strand the rest + * of the pass — the pass stays converging for every other day. + */ + daysSkippedOnConflict: number; + }; +} + +/** True for a Prisma unique-constraint violation (P2002). */ +function isUniqueConstraintViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "P2002" + ); +} + +export interface StepConsolidationOptions { + /** Limit the pass to a single user. Default = every user. */ + userId?: string; + /** Preview-only mode — no DB writes. */ + dryRun?: boolean; + /** Logger sink — defaults to `console.log`. */ + log?: (line: string) => void; +} + +/** + * Group legacy step rows into per-day buckets in the user's timezone. + * Rows already in the daily-stats shape are skipped — they are the + * canonical total, not a legacy sample. Exposed for unit testing the + * bucketing semantics without booting Prisma. + */ +export function bucketLegacyStepRows( + rows: readonly PerSampleRow[], + tz: string, +): Map { + const byDay = new Map(); + for (const row of rows) { + if ( + row.externalId !== null && + row.externalId.startsWith(STEP_DAILY_STATS_PREFIX) + ) { + continue; + } + const key = dayKeyForUserTz(row.measuredAt, tz); + const slot = byDay.get(key) ?? []; + slot.push(row); + byDay.set(key, slot); + } + return byDay; +} + +/** SUM the values in a per-day legacy bucket. */ +export function sumLegacyStepValues(rows: readonly PerSampleRow[]): number { + let acc = 0; + for (const row of rows) acc += row.value; + return acc; +} + +/** + * Run the step consolidation. Idempotent — re-invocation after a + * successful pass reports zero days consolidated because the legacy + * rows are soft-deleted (and so excluded from the live scan). + * + * Does NOT enforce any auth gate — the queue handler owns that concern. + */ +export async function consolidateLegacySteps( + prismaClient: PrismaClient, + options: StepConsolidationOptions = {}, +): Promise { + const dryRun = options.dryRun ?? false; + const log = options.log ?? ((line) => console.log(line)); + + const users = options.userId + ? await prismaClient.user.findMany({ + where: { id: options.userId }, + select: { id: true, timezone: true }, + }) + : await prismaClient.user.findMany({ + select: { id: true, timezone: true }, + }); + + const summary: StepConsolidationSummary = { + dryRun, + buckets: [], + totals: { + usersScanned: users.length, + daysConsolidated: 0, + legacyRowsSoftDeleted: 0, + dailyRowsUpserted: 0, + daysFoldedIntoExisting: 0, + daysSkippedOnConflict: 0, + }, + }; + + for (const user of users) { + const tz = + user.timezone && user.timezone.length > 0 + ? user.timezone + : "Europe/Berlin"; + + // Live legacy step rows whose externalId is NOT the daily-stats + // shape. The `stats:` daily-total row (if present) is read + // separately below per day; we exclude it here so it is never + // soft-deleted. Soft-deleted rows (`deletedAt IS NOT NULL`) are + // excluded so a re-run after a partial pass never re-aggregates. + const legacyRows = (await prismaClient.measurement.findMany({ + where: { + userId: user.id, + type: STEP_TYPE, + deletedAt: null, + NOT: { externalId: { startsWith: STEP_DAILY_STATS_PREFIX } }, + }, + select: { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, + }, + orderBy: { measuredAt: "asc" }, + })) as PerSampleRow[]; + + if (legacyRows.length === 0) continue; + + log(`[step-consolidation] user=${user.id} tz=${tz} legacyRows=${legacyRows.length}${dryRun ? " (dry-run)" : ""}`); + + const byDay = bucketLegacyStepRows(legacyRows, tz); + + for (const [dateKey, dayRows] of byDay) { + if (dayRows.length === 0) continue; + + const legacySum = sumLegacyStepValues(dayRows); + const canonicalTs = canonicalDailyTimestamp(dateKey, tz); + const externalId = dailyStatsExternalId(STEP_HK_IDENTIFIER, dateKey); + + // Does a post-v1.5.0 daily total already exist for this day? If so + // it is the source of truth (HealthKit's own daily aggregate) — + // we do NOT overwrite it and do NOT add the legacy sum on top + // (that would double-count). We still soft-delete the legacy rows. + // `source` is deliberately omitted from this probe (the upsert below + // pins `source: "MANUAL"`): a daily total written by iOS lands as + // `APPLE_HEALTH`, and matching it here is what prevents minting a + // second MANUAL total on top of it. The asymmetry with the upsert + // `where` is intentional, not a bug. + const existingTotal = await prismaClient.measurement.findFirst({ + where: { + userId: user.id, + type: STEP_TYPE, + externalId, + deletedAt: null, + }, + select: { id: true }, + }); + const hadExistingTotal = existingTotal !== null; + + const bucket: StepConsolidationBucket = { + userId: user.id, + dateKey, + legacyRowCount: dayRows.length, + legacySum, + hadExistingTotal, + canonicalTimestamp: canonicalTs.toISOString(), + externalId, + }; + summary.buckets.push(bucket); + summary.totals.daysConsolidated += 1; + if (hadExistingTotal) summary.totals.daysFoldedIntoExisting += 1; + + const ids = dayRows.map((r) => r.id); + + if (!dryRun) { + try { + await prismaClient.$transaction(async (tx) => { + if (!hadExistingTotal) { + // Mint the canonical daily-total row. The unique index + // (userId, type, source, externalId) makes the upsert + // idempotent across re-runs. Source is MANUAL — these are + // historical rows whose original sampling source is no + // longer meaningful once collapsed; the externalId carries + // the canonical daily-stats shape. Built field-by-field + // (no spread) per the no-mass-assignment convention. + await tx.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId: user.id, + type: STEP_TYPE, + source: "MANUAL", + externalId, + }, + }, + create: { + userId: user.id, + type: STEP_TYPE, + value: legacySum, + unit: "steps", + source: "MANUAL", + measuredAt: canonicalTs, + externalId, + }, + update: { + value: legacySum, + measuredAt: canonicalTs, + deletedAt: null, + }, + }); + summary.totals.dailyRowsUpserted += 1; + } + + // Soft-delete the legacy granular rows in the same + // transaction. Tombstone, never hard-delete — they remain as + // an audit/backup trail. `deletedAt` excludes them from every + // live read and from this pass's own re-run discovery. + const del = await tx.measurement.updateMany({ + where: { id: { in: ids }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + summary.totals.legacyRowsSoftDeleted += del.count; + }); + } catch (err) { + // A pre-existing row can collide on the second unique index + // (userId, type, measuredAt, source, sleepStage) when a MANUAL + // step row already sits at this day's canonical-noon instant + // with a null externalId — the mint's `create` branch fires + // (the upsert keys on externalId, which differs) and trips that + // index, rolling back the soft-delete too. Skipping the day + // (rather than aborting the whole user) keeps the pass + // converging: the legacy rows stay live but every other day + // still consolidates, and the run does not re-throw on every + // boot. Non-P2002 errors are genuine failures — rethrow so + // pg-boss retries. + if (!isUniqueConstraintViolation(err)) throw err; + summary.totals.daysConsolidated -= 1; + summary.totals.daysSkippedOnConflict += 1; + summary.buckets.pop(); + log( + `[step-consolidation] user=${user.id} day=${dateKey} skipped — unique-constraint conflict on mint`, + ); + } + } else { + if (!hadExistingTotal) summary.totals.dailyRowsUpserted += 1; + summary.totals.legacyRowsSoftDeleted += ids.length; + } + } + + log( + `[step-consolidation] user=${user.id} complete days=${byDay.size}${dryRun ? " (dry-run)" : ""}`, + ); + } + + log( + `[step-consolidation] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} legacyRowsSoftDeleted=${summary.totals.legacyRowsSoftDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFoldedIntoExisting=${summary.totals.daysFoldedIntoExisting} daysSkippedOnConflict=${summary.totals.daysSkippedOnConflict}${dryRun ? " (dry-run)" : ""}`, + ); + + return summary; +} diff --git a/src/lib/monitoring/glitchtip.ts b/src/lib/monitoring/glitchtip.ts index e14f37af8..bfcbb91ac 100644 --- a/src/lib/monitoring/glitchtip.ts +++ b/src/lib/monitoring/glitchtip.ts @@ -123,14 +123,20 @@ async function tryEnvelope( config: ParsedGlitchtipDsn, body: string, ): Promise { - const response = await safeFetch(config.envelopeUrl, { - method: "POST", - headers: { - "content-type": "application/x-sentry-envelope", + const response = await safeFetch( + config.envelopeUrl, + { + method: "POST", + headers: { + "content-type": "application/x-sentry-envelope", + }, + body, + cache: "no-store", }, - body, - cache: "no-store", - }); + // Operator-configured GlitchTip host carrying the redacted wide-event + // payload — pin the connect-time IP against DNS rebinding. + { requirePublicHost: true }, + ); if (response.ok) return { ok: true, method: "envelope", status: response.status }; @@ -152,14 +158,19 @@ async function tryStoreQuery( target.searchParams.set("sentry_version", "7"); target.searchParams.set("sentry_client", "healthlog/1.0"); - const response = await safeFetch(target, { - method: "POST", - headers: { - "content-type": "application/json", + const response = await safeFetch( + target, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(payload), + cache: "no-store", }, - body: JSON.stringify(payload), - cache: "no-store", - }); + // Operator-configured GlitchTip host — pin the connect-time IP. + { requirePublicHost: true }, + ); if (response.ok) return { ok: true, method: "store-query", status: response.status }; @@ -177,15 +188,20 @@ async function tryStoreHeader( payload: Record, ): Promise { const sentryAuth = `Sentry sentry_version=7, sentry_key=${config.publicKey}, sentry_client=healthlog/1.0`; - const response = await safeFetch(config.storeUrl, { - method: "POST", - headers: { - "content-type": "application/json", - "x-sentry-auth": sentryAuth, + const response = await safeFetch( + config.storeUrl, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-sentry-auth": sentryAuth, + }, + body: JSON.stringify(payload), + cache: "no-store", }, - body: JSON.stringify(payload), - cache: "no-store", - }); + // Operator-configured GlitchTip host — pin the connect-time IP. + { requirePublicHost: true }, + ); if (response.ok) return { ok: true, method: "store-header", status: response.status }; diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 2592c5c08..d43c065d9 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -44,6 +44,7 @@ import { MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; /** * Common envelopes — every HealthLog API response wraps payload in @@ -786,6 +787,29 @@ medicationExtractionSchema.meta({ "Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.", }); +// Insights tile layout — mirrors the Zod schema in +// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived +// from the same `INSIGHTS_TILE_IDS` source so the contract cannot drift. +const insightsLayoutSchema = z + .object({ + version: z.literal(1), + tiles: z + .array( + z.object({ + id: z.enum(INSIGHTS_TILE_IDS), + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }), + ) + .min(1) + .max(50), + }) + .meta({ + id: "InsightsLayoutBody", + description: + "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version; tile ids are a closed enum derived from the server's tile registry.", + }); + // ── Standard 401 / 422 / 429 responses ─────────────────────────────── const stdResponses = { @@ -1579,6 +1603,69 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/layout": { + get: { + tags: ["Insights"], + summary: "Read the calling user's Insights tile layout", + description: + "Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.", + responses: { + "200": { + description: "The resolved layout (custom or default).", + content: { + "application/json": { + schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Insights"], + summary: "Replace the calling user's Insights tile layout", + description: + "Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.", + requestBody: { + required: true, + content: { + "application/json": { schema: insightsLayoutSchema }, + }, + }, + responses: { + "200": { + description: "Layout saved; the normalised layout is echoed back.", + content: { + "application/json": { + schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"), + }, + }, + }, + // 422 (multi-issue validation envelope) comes from stdResponses. + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Reset the calling user's Insights tile layout", + description: + "Clears the persisted layout and returns the default layout. Idempotent.", + responses: { + "200": { + description: "Layout reset; the default layout is returned.", + content: { + "application/json": { + schema: dataEnvelope( + insightsLayoutSchema, + "InsightsLayoutReset", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/chat/messages/{id}/feedback": { post: { tags: ["Insights"], diff --git a/src/lib/queries/use-analytics-query.ts b/src/lib/queries/use-analytics-query.ts index f0bcd6d61..7411c5183 100644 --- a/src/lib/queries/use-analytics-query.ts +++ b/src/lib/queries/use-analytics-query.ts @@ -100,9 +100,10 @@ export interface UseAnalyticsQueryOptions { async function fetchAnalytics( slice: AnalyticsSlice, ): Promise { - const url = - slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics"; - const res = await fetch(url); + const res = + slice === "summaries" + ? await fetch("/api/analytics?slice=summaries") + : await fetch("/api/analytics"); if (!res.ok) { throw new Error(`Failed to load analytics (${res.status})`); } diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index daa685442..7879522bc 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -3,6 +3,7 @@ * Docs: https://developer.withings.com/api-reference */ import { getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; import { WithingsApiError, classifyWithingsResponse, @@ -109,7 +110,7 @@ export async function exchangeCode( }); const start = performance.now(); - const res = await fetch(WITHINGS_TOKEN_URL, { + const res = await safeFetch(WITHINGS_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), @@ -152,7 +153,7 @@ export async function refreshAccessToken( }); const start = performance.now(); - const res = await fetch(WITHINGS_TOKEN_URL, { + const res = await safeFetch(WITHINGS_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), @@ -269,7 +270,7 @@ export async function fetchMeasurements( }); const pageStart = performance.now(); - const res = await fetch(WITHINGS_MEASURE_URL, { + const res = await safeFetch(WITHINGS_MEASURE_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -346,7 +347,7 @@ export async function subscribeWebhook( }); const start = performance.now(); - const res = await fetch(WITHINGS_NOTIFY_URL, { + const res = await safeFetch(WITHINGS_NOTIFY_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -397,7 +398,7 @@ export async function unsubscribeWebhook( }); const start = performance.now(); - const res = await fetch(WITHINGS_NOTIFY_URL, { + const res = await safeFetch(WITHINGS_NOTIFY_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 36450b5f3..0a2428242 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -37,6 +37,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; import { getUnitForType } from "@/lib/validations/measurement"; import { collapseToTypeDayKeys, @@ -148,7 +149,7 @@ export async function fetchWithingsActivity( offset: String(offset), }); const pageStart = performance.now(); - const res = await fetch(WITHINGS_MEASURE_URL, { + const res = await safeFetch(WITHINGS_MEASURE_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 3c4b0c4f8..a94eb9322 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -35,6 +35,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; import { getUnitForType } from "@/lib/validations/measurement"; import { collapseToTypeDayKeys, @@ -118,7 +119,7 @@ export async function fetchWithingsSleep( }); const pageStart = performance.now(); - const res = await fetch(WITHINGS_SLEEP_URL, { + const res = await safeFetch(WITHINGS_SLEEP_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/tests/integration/consolidate-legacy-steps.test.ts b/tests/integration/consolidate-legacy-steps.test.ts new file mode 100644 index 000000000..b350e7fcd --- /dev/null +++ b/tests/integration/consolidate-legacy-steps.test.ts @@ -0,0 +1,228 @@ +/** + * v1.5.6 — real-Postgres integration coverage for + * `consolidateLegacySteps()`. Mirrors the + * `drain-per-sample-cumulative` fixture: the testcontainer is hot + * before this file loads, so the body truncates + seeds + consolidates. + * + * Pins the contracts the unit mocks can't: + * - legacy granular rows collapse into one daily total with the + * correct sum; + * - the originals are SOFT-deleted (tombstoned, not gone); + * - a day already holding a `stats:` total is not double-counted; + * - the discovery query is idempotent (second run = no-op). + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { consolidateLegacySteps } from "@/lib/measurements/consolidate-legacy-steps"; + +const TEST_USER_ID = "user-step-consolidation"; +const STEP_TOTAL_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-05-16"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "step-consolidation", + email: "step-consolidation@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +describe("consolidateLegacySteps (real Postgres)", () => { + it("collapses legacy granular step rows into one daily total and soft-deletes the originals", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-legacy-1", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 3400, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + externalId: "hk-legacy-2", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T20:00:00.000Z"), + externalId: "hk-legacy-3", + }, + ], + }); + + const summary = await consolidateLegacySteps(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + + expect(summary.totals.daysConsolidated).toBe(1); + expect(summary.totals.legacyRowsSoftDeleted).toBe(3); + expect(summary.totals.dailyRowsUpserted).toBe(1); + expect(summary.totals.daysFoldedIntoExisting).toBe(0); + + // One live daily total carrying the canonical externalId + sum. + const live = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null }, + }); + expect(live).toHaveLength(1); + expect(live[0].value).toBe(5400); + expect(live[0].externalId).toBe(STEP_TOTAL_ID); + + // Originals are tombstoned, NOT gone — audit trail preserved. + const tombstoned = await prisma.measurement.findMany({ + where: { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + deletedAt: { not: null }, + }, + }); + expect(tombstoned).toHaveLength(3); + expect(tombstoned.every((r) => r.externalId?.startsWith("hk-legacy-"))).toBe( + true, + ); + }); + + it("does not double-count when a stats: daily total already exists for the day", async () => { + const prisma = getPrismaClient(); + // Post-v1.5.0 daily total already written by iOS. + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 9000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T10:00:00.000Z"), + externalId: STEP_TOTAL_ID, + }, + }); + // Legacy granular rows for the same day. + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-legacy-a", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T20:00:00.000Z"), + externalId: "hk-legacy-b", + }, + ], + }); + + const summary = await consolidateLegacySteps(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + + expect(summary.totals.daysConsolidated).toBe(1); + expect(summary.totals.daysFoldedIntoExisting).toBe(1); + // No new daily row minted — the existing total stands. + expect(summary.totals.dailyRowsUpserted).toBe(0); + expect(summary.totals.legacyRowsSoftDeleted).toBe(2); + + const live = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null }, + }); + expect(live).toHaveLength(1); + // The existing total is untouched — NOT 9000 + 1200 + 800. + expect(live[0].value).toBe(9000); + expect(live[0].externalId).toBe(STEP_TOTAL_ID); + + // Both legacy rows tombstoned. + const tombstoned = await prisma.measurement.count({ + where: { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + deletedAt: { not: null }, + }, + }); + expect(tombstoned).toBe(2); + }); + + it("is idempotent — a second run is a no-op", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 2000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T09:00:00.000Z"), + externalId: "hk-legacy-x", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 3000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T18:00:00.000Z"), + externalId: "hk-legacy-y", + }, + ], + }); + + const first = await consolidateLegacySteps(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + expect(first.totals.daysConsolidated).toBe(1); + expect(first.totals.legacyRowsSoftDeleted).toBe(2); + + const second = await consolidateLegacySteps(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + expect(second.totals.daysConsolidated).toBe(0); + expect(second.totals.legacyRowsSoftDeleted).toBe(0); + expect(second.totals.dailyRowsUpserted).toBe(0); + + // State is stable: one live total (5000), two tombstones. + const live = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", deletedAt: null }, + }); + expect(live).toHaveLength(1); + expect(live[0].value).toBe(5000); + const tombstoned = await prisma.measurement.count({ + where: { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + deletedAt: { not: null }, + }, + }); + expect(tombstoned).toBe(2); + }); +});