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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 22:07:10 +02:00
1 parent d54addd61a
commit 8528c3b4fe
54 files changed
+2343 -324

No files matched your search

+30
View File
@@ -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 `<AdvancedSettingsSheet>` 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.
+1
View File
@@ -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: `<surface>:${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.
+198 -1
View File
@@ -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:
+19
View File
@@ -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,
},
};
@@ -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;
+13 -2
View File
@@ -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",
},
},
]);
+7
View File
@@ -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"
}
}
},
+7
View File
@@ -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"
}
}
},
+8 -1
View File
@@ -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?"
}
}
}
+8 -1
View File
@@ -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 ?"
}
}
}
+8 -1
View File
@@ -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?"
}
}
}
+8 -1
View File
@@ -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?"
}
}
}
+1 -1
View File
@@ -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",
@@ -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;
+5
View File
@@ -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")
}
@@ -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({
+1 -1
View File
@@ -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",
+10 -3
View File
@@ -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,
+14 -9
View File
@@ -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;
@@ -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<Uint8Array>({
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.
+83 -1
View File
@@ -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<Uint8Array> | null,
maxBytes: number,
): Promise<Uint8Array<ArrayBuffer>> {
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,
+79 -111
View File
@@ -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
* `<AdvancedSettingsSheet>`, 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<MedicationPayload | null>(
() => (medication ? snapshotToWizardPayload(medication) : null),
[medication],
);
if (authLoading || isLoading) {
return (
<div
@@ -246,23 +229,18 @@ export default function MedicationDetailPage({
}
const oneShot = medication.oneShot === true;
const todayEvent = intakeList?.events.find(
(e) =>
(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({
</Link>
</Button>
{/* §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. */}
<MedicationDetailHeader
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
onEdit={() => openWizardWithIntent("name")}
onEditPlan={() => setWizardOpen(true)}
onOpenAdvanced={() => setAdvancedOpen(true)}
/>
{/* §9.2 — Today's dose card */}
<TodaysDoseCard
medicationId={id}
active={medication.active}
oneShot={oneShot}
scheduledForToday={!oneShot || !todayEvent}
alreadyTakenAt={
todayEvent && !todayEvent.skipped ? todayEvent.takenAt : null
}
alreadySkipped={Boolean(todayEvent?.skipped)}
/>
{/* §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 ? (
<Card
className="p-5 sm:p-6"
@@ -318,13 +287,14 @@ export default function MedicationDetailPage({
</Card>
) : (
<CadenceSummaryRow
medication={snapshotToWizardPayload(medication)}
onEdit={() => 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" && (
<>
<DrugLevelChart
@@ -338,54 +308,52 @@ export default function MedicationDetailPage({
</>
)}
{/* §9.5 — Intake history preview */}
{/* G-1 §3.5 — Intake history table (primary surface). */}
<IntakeHistoryPreview
medicationId={id}
importOpen={importOpen}
onImportOpenChange={setImportOpen}
/>
{/* §9.6 + §9.7 — Notifications + Settings: recurring path only. */}
{!oneShot && (
<>
<NotificationsSection
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
/>
<Separator className="opacity-0" />
<SettingsSection
medicationId={id}
medicationName={medication.name}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
reminderGraceMinutes={primaryGrace}
/>
</>
)}
{/* §9.8 — Verwaltung & Gefahrenzone */}
<DestructiveZoneSection
{/* G-1 §5 — Advanced settings sheet. Hosts Notifications →
Settings → destructive zone. The Phasen button sibling-swaps
to the phase sheet below. */}
<AdvancedSettingsSheet
open={advancedOpen}
onOpenChange={setAdvancedOpen}
medicationId={id}
medicationName={medication.name}
treatmentClass={medication.treatmentClass}
active={medication.active}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
notificationsEnabled={medication.notificationsEnabled}
reminderGraceMinutes={primaryGrace}
intakeCount={intakeCount}
onRequestPhaseSheet={openPhaseSheet}
/>
{/* 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) && (
<PhaseConfigSheet
medicationId={id}
open={phaseSheetOpen}
onOpenChange={setPhaseSheetOpen}
/>
)}
{/* Wizard mount — edit-only, lands on Step 1 (no landing
intent). Cancel + success both close via the cascade. */}
<MedicationWizardDialog
open={wizardOpen}
onOpenChange={(open) => {
if (!open) closeWizard();
}}
onOpenChange={setWizardOpen}
mode="edit"
initial={snapshotToWizardPayload(medication)}
landingIntent={wizardIntent}
onSuccess={() => closeWizard()}
initial={payload}
onSuccess={() => setWizardOpen(false)}
/>
</div>
);
@@ -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");
});
});
@@ -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.
* `<ResponsiveSheet>` 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 `<SettingsSection>`;
* - 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 ? (
<div data-slot="mock-responsive-sheet">
<div data-slot="mock-title">{title}</div>
{children}
</div>
) : null,
}));
vi.mock(
"@/components/medications/sections/notifications-section",
() => ({
NotificationsSection: () => <div data-slot="mock-notifications" />,
}),
);
vi.mock("@/components/medications/sections/settings-section", () => ({
SettingsSection: ({
onRequestPhaseSheet,
}: {
onRequestPhaseSheet?: () => void;
}) => (
<div
data-slot="mock-settings"
data-has-phase-swap={onRequestPhaseSheet ? "yes" : "no"}
/>
),
}));
vi.mock(
"@/components/medications/sections/destructive-zone-section",
() => ({
DestructiveZoneSection: ({
onAfterAction,
}: {
onAfterAction?: () => void;
}) => (
<button
data-slot="mock-destructive"
data-has-after-action={onAfterAction ? "yes" : "no"}
onClick={onAfterAction}
/>
),
}),
);
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
function baseProps(open: boolean, onRequestPhaseSheet?: () => void) {
return {
open,
onOpenChange: () => {},
medicationId: "med-1",
medicationName: "Test Drug",
treatmentClass: "GLP1",
active: true,
startsOn: "2026-01-01",
endsOn: "2026-06-01",
notificationsEnabled: true,
reminderGraceMinutes: 30,
intakeCount: 5,
onRequestPhaseSheet,
};
}
describe("AdvancedSettingsSheet (G-1 §5)", () => {
it("renders the three sections in routine → rare → destructive order", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
);
const notif = html.indexOf("mock-notifications");
const settings = html.indexOf("mock-settings");
const destructive = html.indexOf("mock-destructive");
expect(notif).toBeGreaterThan(-1);
expect(settings).toBeGreaterThan(notif);
expect(destructive).toBeGreaterThan(settings);
});
it("threads onRequestPhaseSheet through to the settings section", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
);
expect(html).toContain('data-has-phase-swap="yes"');
});
it("passes an onAfterAction to the destructive zone so it can close the sheet", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
expect(html).toContain('data-has-after-action="yes"');
});
it("renders nothing when closed", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(false)} />,
);
expect(html).toBe("");
});
it("titles the sheet with the advanced-settings key", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
expect(html).toContain("medications.detail.advanced.title");
});
});
@@ -0,0 +1,79 @@
/**
* v1.5.6 G-1 §9 Q1 — IntakeEditDialog seeds from the row's real
* values.
*
* Before this pass the preview opened the dialog with a stub
* `{ id, takenAt: null, skipped: false }`, so "Bearbeiten" always
* showed an empty form. The fix threads the whole `IntakeEvent` up
* through `onEditIntake(event)`; this test pins that the dialog's
* datetime-local input + skipped switch seed from the event passed in.
*
* `<Dialog>` wraps a Radix portal that `renderToStaticMarkup` does not
* materialise, so we mock the dialog primitives to passthroughs.
*/
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("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dialog">{children}</div>
),
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
describe("IntakeEditDialog seed (G-1 §9 Q1)", () => {
it("seeds the datetime-local input from the event's real takenAt", () => {
// 2026-03-04T09:30 in local time — the dialog formats to
// `YYYY-MM-DDTHH:mm` in the user's timezone.
const taken = new Date(2026, 2, 4, 9, 30).toISOString();
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-1", takenAt: taken, skipped: false }}
onClose={() => {}}
/>,
);
expect(html).toContain('value="2026-03-04T09:30"');
});
it("seeds the skipped switch from a skipped event (no stub default)", () => {
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-2", takenAt: null, skipped: true }}
onClose={() => {}}
/>,
);
// Radix Switch reflects checked state via data-state.
expect(html).toContain('data-state="checked"');
});
it("renders nothing when no event is selected", () => {
const html = renderToStaticMarkup(
<IntakeEditDialog medicationId="med-1" event={null} onClose={() => {}} />,
);
expect(html).toBe("");
});
});
@@ -124,7 +124,7 @@ const mounjaro: Glp1Medication = {
};
describe("medication card symmetry — Ramipril vs Mounjaro", () => {
it("both cards render History + Pencil header buttons", () => {
it("both cards render the detail-nav + Pencil header buttons", () => {
const client = makeClient();
seedCompliance(client, ramipril.id);
seedCompliance(client, mounjaro.id);
@@ -140,7 +140,10 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
);
for (const html of [ramiprilHtml, mounjaroHtml]) {
expect(html).toContain("lucide-history");
// v1.5.6 F-1 M-1 — the detail-nav icon swapped from the
// history glyph to a neutral chevron now that it routes to the
// detail page rather than the history sub-route.
expect(html).toContain("lucide-chevron-right");
expect(html).toContain("lucide-pencil");
}
});
@@ -26,6 +26,35 @@ vi.mock("@/lib/i18n/context", () => ({
}),
}));
// `<DropdownMenu>` wraps a Radix portal that `renderToStaticMarkup`
// does not materialise while closed; mock the primitives to
// passthroughs so the trigger + menu items are reachable in static
// markup and the `onSelect` wiring is assertable (G-1 §4).
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dropdown">{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dropdown-trigger">{children}</div>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dropdown-content">{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
...rest
}: {
children: React.ReactNode;
onSelect?: () => void;
[key: string]: unknown;
}) => (
<button onClick={onSelect} {...rest}>
{children}
</button>
),
}));
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
@@ -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 `<ResponsiveSheet>`.
*
* 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 `<PhaseConfigSheet>` 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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.detail.advanced.title")}
className="sm:max-w-lg"
bodyClassName="gap-6"
>
<div
className="space-y-6"
data-slot="advanced-settings-sheet-body"
>
{/* routine */}
<NotificationsSection
medicationId={medicationId}
notificationsEnabled={notificationsEnabled}
/>
{/* rare */}
<SettingsSection
medicationId={medicationId}
medicationName={medicationName}
treatmentClass={treatmentClass}
startsOn={startsOn}
endsOn={endsOn}
reminderGraceMinutes={reminderGraceMinutes}
onRequestPhaseSheet={onRequestPhaseSheet}
/>
{/* destructive */}
<DestructiveZoneSection
medicationId={medicationId}
medicationName={medicationName}
active={active}
intakeCount={intakeCount}
onAfterAction={() => onOpenChange(false)}
/>
</div>
</ResponsiveSheet>
);
}
@@ -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).
@@ -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")}
>
<Link href={`/medications/${medication.id}`}>
<History className="h-4 w-4" />
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
<Button
@@ -73,7 +73,7 @@ import {
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
interface IntakeEvent {
export interface IntakeEvent {
id: string;
medicationId: string;
scheduledFor: string;
@@ -108,9 +108,12 @@ interface IntakeHistoryListV2Props {
pageSize?: number;
/**
* v1.5.5 F-1 C-2 — per-row Bearbeiten callback. When provided the
* row renders a kebab `<DropdownMenu>` with an Edit action.
* row renders a kebab `<DropdownMenu>` with an Edit action. v1.5.6
* G-1 §9 Q1: threads the whole `IntakeEvent` (not just the id) so
* the edit dialog seeds from the row's real `takenAt` / `skipped`
* instead of an empty stub.
*/
onEditIntake?: (eventId: string) => void;
onEditIntake?: (event: IntakeEvent) => void;
/**
* v1.5.5 F-1 C-2 — per-row Löschen callback. When provided the row
* renders a kebab `<DropdownMenu>` with a Delete action.
@@ -380,7 +383,7 @@ export function IntakeHistoryListV2({
<DropdownMenuContent align="end">
{onEditIntake && (
<DropdownMenuItem
onSelect={() => onEditIntake(event.id)}
onSelect={() => onEditIntake(event)}
data-slot="intake-history-row-edit"
>
{t("medications.detail.intake.rowActions.edit")}
@@ -25,7 +25,7 @@ import {
Flame,
Pencil,
Loader2,
History,
ChevronRight,
} from "lucide-react";
import Link from "next/link";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -275,7 +275,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
aria-label={t("medications.openDetailPage")}
>
<Link href={`/medications/${medication.id}`}>
<History className="h-4 w-4" />
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
<Button
@@ -18,10 +18,16 @@
* destructive zone, not on the header (E-2 M-1).
*/
import { Pencil } from "lucide-react";
import { ChevronDown, Pencil } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
export interface MedicationDetailHeaderProps {
@@ -30,11 +36,15 @@ export interface MedicationDetailHeaderProps {
active: boolean;
endsOn?: string | null;
/**
* Fires when the user taps the Bearbeiten pencil. The detail page
* routes this to the wizard with `landingIntent: "name"` so the
* user lands on Step 1.
* v1.5.6 G-1 §4 — "Plan bearbeiten" opens the wizard (mode="edit",
* Step 1). Fired from the Bearbeiten dropdown's first item.
*/
onEdit: () => void;
onEditPlan: () => void;
/**
* v1.5.6 G-1 §4 — "Erweiterte Einstellungen" opens the
* `<AdvancedSettingsSheet>`. Fired from the dropdown's second item.
*/
onOpenAdvanced: () => void;
}
type Status = "active" | "paused" | "ended";
@@ -58,7 +68,8 @@ export function MedicationDetailHeader({
dose,
active,
endsOn,
onEdit,
onEditPlan,
onOpenAdvanced,
}: MedicationDetailHeaderProps) {
const { t } = useTranslations();
const status = resolveStatus(active, endsOn);
@@ -98,16 +109,34 @@ export function MedicationDetailHeader({
</Badge>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={onEdit}
className="min-h-11 sm:min-h-9"
data-slot="medication-detail-edit-button"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="medication-detail-edit-button"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
<ChevronDown aria-hidden="true" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={onEditPlan}
data-slot="medication-detail-edit-plan"
>
{t("medications.detail.edit.planOption")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={onOpenAdvanced}
data-slot="medication-detail-edit-advanced"
>
{t("medications.detail.edit.advancedOption")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,71 @@
/**
* v1.5.6 G-1 §5 — phase sibling-swap wiring on `<SettingsSection>`.
*
* When the hosting `<AdvancedSettingsSheet>` passes
* `onRequestPhaseSheet`, the section must NOT mount its own
* `<PhaseConfigSheet>` — 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.
*
* `<PhaseConfigSheet>` 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: () => <div data-slot="mock-api-tokens" />,
}));
vi.mock("@/components/medications/sections/phase-config-sheet", () => ({
PhaseConfigSheet: () => <div data-slot="mock-phase-config-sheet" />,
}));
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(
<QueryClientProvider client={makeClient()}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
// 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(
<SettingsSection {...glp1Props} onRequestPhaseSheet={() => {}} />,
);
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(<SettingsSection {...glp1Props} />);
expect(html).toContain("medication-detail-phase-management-button");
expect(html).toContain("mock-phase-config-sheet");
});
});
@@ -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 {
@@ -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<Set<string>>(new Set());
const [editingId, setEditingId] = useState<string | null>(null);
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
@@ -206,7 +209,7 @@ export function IntakeHistoryPreview({
<IntakeHistoryListV2
medicationId={medicationId}
pageSize={14}
onEditIntake={setEditingId}
onEditIntake={setEditingEvent}
onDeleteIntake={setPendingDeleteId}
selection={{
mode: "multi",
@@ -226,19 +229,25 @@ export function IntakeHistoryPreview({
</div>
</MedicationDetailSection>
<IntakeImportDialog
medicationId={importOpen ? medicationId : null}
onClose={() => onImportOpenChange(false)}
/>
{importOpen && (
<IntakeImportDialog
medicationId={medicationId}
onClose={() => onImportOpenChange(false)}
/>
)}
<IntakeEditDialog
medicationId={medicationId}
event={
editingId
? { id: editingId, takenAt: null, skipped: false }
editingEvent
? {
id: editingEvent.id,
takenAt: editingEvent.takenAt,
skipped: editingEvent.skipped,
}
: null
}
onClose={() => setEditingId(null)}
onClose={() => setEditingEvent(null)}
/>
<AlertDialog
@@ -40,6 +40,15 @@ export interface SettingsSectionProps {
startsOn?: string | null;
endsOn?: string | null;
reminderGraceMinutes?: number | null;
/**
* v1.5.6 G-1 §5 — sibling-swap the phase sheet. When provided, the
* Phasen button hands control to the parent (which closes the
* hosting sheet before opening `<PhaseConfigSheet>`) 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({
<Button
variant="outline"
size="sm"
onClick={() => setPhaseSheetOpen(true)}
onClick={() =>
onRequestPhaseSheet
? onRequestPhaseSheet()
: setPhaseSheetOpen(true)
}
className="min-h-11 sm:min-h-9"
data-slot="medication-detail-phase-management-button"
>
@@ -196,7 +210,7 @@ export function SettingsSection({
</div>
</div>
{showPhases && (
{showPhases && !onRequestPhaseSheet && (
<PhaseConfigSheet
medicationId={medicationId}
open={phaseSheetOpen}
@@ -87,14 +87,6 @@ export interface MedicationWizardDialogProps {
mode: "create" | "edit";
/** Required when `mode === "edit"`. */
initial?: MedicationPayload;
/**
* v1.5.5 C-E2-2 — call-site intent that drives the landing step on
* an edit-open. The detail page's cadence-summary row passes
* `"cadence"` so the user lands on Step 5; the header pencil passes
* `"name"` so the user lands on Step 1. Defaults to `undefined`
* which keeps the legacy 1-or-8 heuristic.
*/
landingIntent?: "cadence" | "summary" | "name";
/** Fires with the medication id on a successful create / save. */
onSuccess?: (id: string) => 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 <WizardDialogShell key={stateKey} {...props} />;
}
@@ -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<StepNumber>(() => {
if (mode === "edit" && initial) {
const hydrated = hydrateWizardPayload(initial);
return landingStepForEdit(hydrated, landingIntent) as StepNumber;
return landingStepForEdit(hydrated) as StepNumber;
}
return 1;
});
@@ -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);
@@ -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;
}
+6 -2
View File
@@ -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<Response> {
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 });
}
/**
+5 -4
View File
@@ -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<DeviceCodeStart> {
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<DevicePollResult> {
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<CodexCreds> {
const res = await fetch(`${ISSUER}/oauth/token`, {
const res = await safeFetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+66
View File
@@ -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<StepConsolidationPayload>(
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.
+137
View File
@@ -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 (`<surface>.<noun>.<verb>`). 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:<HK>:<YYYY-MM-DD>`. 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<Array<{ id: string }>>`
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),
};
}
}
+3 -1
View File
@@ -72,7 +72,9 @@ async function flushLokiBuffer(): Promise<void> {
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
@@ -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<PerSampleRow> & { 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);
});
});
@@ -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:<dateKey>`. 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:<day>`
* 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<string, PerSampleRow[]> {
const byDay = new Map<string, PerSampleRow[]>();
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<StepConsolidationSummary> {
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;
}
+38 -22
View File
@@ -123,14 +123,20 @@ async function tryEnvelope(
config: ParsedGlitchtipDsn,
body: string,
): Promise<GlitchtipDeliveryResult> {
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<string, unknown>,
): Promise<GlitchtipDeliveryResult> {
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 };
+87
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+4 -3
View File
@@ -100,9 +100,10 @@ export interface UseAnalyticsQueryOptions {
async function fetchAnalytics(
slice: AnalyticsSlice,
): Promise<AnalyticsRawPayload> {
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})`);
}
+6 -5
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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);
});
});