From d10ae9e71c15aaa2a4df3acfb22f9992172bf96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:04:58 +0200 Subject: [PATCH 01/39] test(e2e): gate every measurement on its element, and keep it that way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four specs navigated and then read the DOM once, with nothing in between that waits for anything but the network. An idle network says nothing about whether React has rendered, so the read can sample a tree that is still mounting and the assertion then blames the application for a race inside the test. Two such reads went red on consecutive CI runs, on different specs. The settings over-scroll guard also slept a fixed 500 ms, which is the wrong shape twice over: too long when the page is ready, too short on a loaded runner, and it never says what it is waiting for. The structural guard runs in the unit suite rather than in Playwright, because e2e runs at PR-CI only and flaky counts as failed there — one unguarded read otherwise costs a whole pipeline before anyone sees it. It refuses to count a wait whose rejection is thrown away, since that reads like a guarantee and is not one, and would leave the check defeatable by appending a catch. The one deliberate swallow is on the allowlist with its reason: a genuine regression there still fails at the width assertion below it, which reports the measured number instead of a timeout message. --- e2e/mobile-density.spec.ts | 14 +- e2e/mobile-viewport.spec.ts | 6 +- e2e/settings-overscroll.spec.ts | 9 +- e2e/utils/settle.ts | 39 ++++ .../e2e-measurement-gating-guard.test.ts | 175 ++++++++++++++++++ 5 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 e2e/utils/settle.ts create mode 100644 src/__tests__/e2e-measurement-gating-guard.test.ts diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index af0e0836d..00f13fbe9 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { settleBeforeMeasure } from "./utils/settle"; import { LONG_HEADLINE_BRIEFING, MOCK_SCORE_RINGS, @@ -162,7 +163,16 @@ test.describe("phone-width density guard", () => { await mockMoodInsights(page); } await page.goto(route, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); + + if (route === "/insights") { + // The one route here that had no readiness signal, while its two + // siblings below both have one. An overflow guard against a page + // that has not finished rendering passes for the same reason an + // empty page passes: there is nothing wide on it yet. + await expect( + page.locator('[data-slot="section-heading"]').first(), + ).toBeVisible(); + } if (route === "/") { // The guard must not pass vacuously — the Today hero and its @@ -186,6 +196,8 @@ test.describe("phone-width density guard", () => { ).toBeVisible(); } + await settleBeforeMeasure(page, page.locator("main#main-content")); + // The document/body pair alone cannot see the app's sideways scroll: // `AuthShell` scrolls inside `
`, whose // `overflow-y:auto` makes `overflow-x` compute to `auto`, so an diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts index 200bf4c28..1565fa86d 100644 --- a/e2e/mobile-viewport.spec.ts +++ b/e2e/mobile-viewport.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { settleBeforeMeasure } from "./utils/settle"; import { mockDashboardSnapshot, WEIGHT_ONLY_SUMMARIES, @@ -78,7 +79,10 @@ test.describe("mobile-viewport smoke", () => { ); await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); + // Gate on the dashboard's own content, not on the network. Every read + // below is geometric and happens exactly once, so measuring a tree that + // is still mounting reports a layout nobody will ever see. + await settleBeforeMeasure(page, page.locator("main#main-content")); // 1) No horizontal scroll const dims = await page.evaluate(() => ({ diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index b55eb33cc..dc7ab9ec3 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { settleBeforeMeasure } from "./utils/settle"; /** * Vertical over-scroll regression guard (the recurring class behind the @@ -63,9 +64,11 @@ test.describe("settings + admin vertical over-scroll guard", () => { test(`no over-scroll on ${route} (${vp.name})`, async ({ page }) => { await page.setViewportSize({ width: vp.width, height: vp.height }); await page.goto(route, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); - // Let entry transitions / late queries settle before measuring. - await page.waitForTimeout(500); + // Was a fixed 500 ms sleep, which is the wrong shape twice over: too + // long when the page is ready and too short on a loaded runner, and + // it never says what it is waiting for. Gate on the element the + // measurement reads instead. + await settleBeforeMeasure(page, page.locator("#main-content")); const dims = await page.evaluate(() => { const main = document.getElementById("main-content"); diff --git a/e2e/utils/settle.ts b/e2e/utils/settle.ts new file mode 100644 index 000000000..14480ab7b --- /dev/null +++ b/e2e/utils/settle.ts @@ -0,0 +1,39 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** + * Gate a one-shot DOM read on the element it is about to measure. + * + * The failure this exists to prevent: a spec navigates with + * `waitUntil: "networkidle"`, then reads the DOM in a single + * `page.evaluate` / `evaluateAll` that does not retry. An idle network says + * nothing about whether React has rendered, so the read can land on a tree + * that is still mounting and the assertion then blames the application for + * something the test raced — "language + dob fields must exist" when the + * fields were simply not there yet, or a measurement helper accused of + * measuring the wrong element when it was measuring the right one too early. + * + * Two such tests went red on consecutive CI runs during v1.33.0. Neither + * reproduces locally: a developer machine is fast enough that the render + * always wins the race, so the only place this shows up is a loaded runner, + * and `failOnFlakyTests` means every occurrence costs a whole red pipeline + * even when the retry passes. + * + * `sentinel` must be something the measurement actually depends on, not just + * any element on the page. Gating on a layout shell that renders before the + * data proves nothing. + */ +export async function settleBeforeMeasure( + page: Page, + sentinel: Locator, + options: { attachedOnly?: boolean } = {}, +): Promise { + if (options.attachedOnly) { + await expect(sentinel).toBeAttached(); + } else { + await expect(sentinel).toBeVisible(); + } + // Fonts and late CSS change geometry after the element is visible, and + // every measurement in this suite is geometric. + await page.waitForLoadState("networkidle").catch(() => {}); + await page.evaluate(() => document.fonts?.ready); +} diff --git a/src/__tests__/e2e-measurement-gating-guard.test.ts b/src/__tests__/e2e-measurement-gating-guard.test.ts new file mode 100644 index 000000000..6f4970f75 --- /dev/null +++ b/src/__tests__/e2e-measurement-gating-guard.test.ts @@ -0,0 +1,175 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * A one-shot DOM read must be gated on the element it measures, not on the + * network. + * + * The failure this catches, twice on consecutive CI runs during v1.33.0: + * a spec navigates with `waitUntil: "networkidle"` and then reads the DOM in + * a single `page.evaluate` / `evaluateAll` that never retries. An idle + * network says nothing about whether React has rendered, so the read can land + * on a tree that is still mounting. The assertion then blames the application + * — "language + dob fields must exist", "the helper is measuring the wrong + * element" — for what is a race inside the test. + * + * Why this guard lives in the unit suite rather than in Playwright: e2e runs + * at PR-CI only, and `playwright.config.ts` sets `failOnFlakyTests` on CI, so + * one unguarded read costs a whole red pipeline before anyone sees it. Here it + * is red in seconds, on the machine that wrote it. + * + * It is deliberately a source-shape check rather than a runtime one. The + * shape is what a developer gets wrong; the runtime symptom only appears on a + * loaded runner, which is precisely where it is most expensive to discover. + */ + +const E2E_DIR = resolve(__dirname, "../../e2e"); + +/** + * Sites where a `goto` → `evaluate` with no locator gate is correct, each with + * the reason. Keep this list short: it is an inventory of exceptions, not a + * place to put anything inconvenient. Past ~15 entries the guard is asking the + * wrong question and should be rethought rather than extended. + */ +const ALLOWLIST: ReadonlyArray<{ file: string; test: string; why: string }> = [ + { + file: "ipad-viewport.spec.ts", + test: "no horizontal overflow and full-width content on", + why: "Deliberate swallowed waitForFunction. The rail settle can land just after networkidle on a loaded runner, but a genuine regression — the rail never engaging — still fails at the width assertion below, which reports the actual measured number. A hard gate here would replace that number with a generic timeout message, so the swallow buys a better diagnostic rather than hiding a race.", + }, +]; + +interface Offence { + file: string; + testName: string; + line: number; +} + +/** Source positions of every `test(` / `test.only(` block opener. */ +function testBlocks( + source: string, +): Array<{ name: string; start: number; end: number }> { + const blocks: Array<{ name: string; start: number; end: number }> = []; + const opener = /\btest(?:\.only|\.fixme|\.skip)?\s*\(\s*(`|"|')([\s\S]*?)\1/g; + const starts: Array<{ name: string; index: number }> = []; + for (let m = opener.exec(source); m !== null; m = opener.exec(source)) { + starts.push({ name: m[2], index: m.index }); + } + for (let i = 0; i < starts.length; i += 1) { + blocks.push({ + name: starts[i].name, + start: starts[i].index, + end: i + 1 < starts.length ? starts[i + 1].index : source.length, + }); + } + return blocks; +} + +const GOTO = /\bpage\.goto\s*\(/g; +/** A read that samples the DOM once and does not retry. */ +const ONE_SHOT_READ = + /\b(?:page|\w+)\.(?:evaluate|evaluateAll|evaluateHandle|\$\$eval|\$eval)\s*\(/; +/** + * Anything that waits for the DOM to reach a state before the read. + * + * No leading `\b`: the common form is `page.locator(…).first().waitFor(…)`, + * where the character before the dot is `)`. There is no word boundary + * between `)` and `.`, so a `\b` here silently fails to match the most + * frequent gate in the suite and reports correct tests as offenders. That is + * worse than no guard, because the fix someone reaches for is to allowlist a + * test that was never wrong. + */ +const GATE = + /(?:await\s+expect(?:\.poll|\.soft)?\s*\(|\.waitFor\s*\(|waitForSelector\s*\(|waitForFunction\s*\(|settleBeforeMeasure\s*\(|settleForOverflowMeasurement\s*\(|toBeVisible\s*\(|toBeAttached\s*\()/; + +function lineOf(source: string, index: number): number { + return source.slice(0, index).split("\n").length; +} + +/** + * A wait whose rejection is thrown away is not a gate. + * + * `await page.waitForFunction(...).catch(() => {})` reads like a guarantee and + * is not one: on timeout it proceeds to the measurement exactly as if it had + * never waited. Counting it would also leave the guard trivially defeatable — + * append `.catch(() => {})` to any wait and the check goes quiet. Where the + * swallow is deliberate, the allowlist is the place to say so. + */ +const SWALLOWED_WAIT = + /(?:waitForFunction|waitForSelector|waitFor)\s*\([\s\S]*?\)\s*\n?\s*\.catch\s*\(/; + +function stripSwallowedWaits(segment: string): string { + return segment.replace(new RegExp(SWALLOWED_WAIT, "g"), "«swallowed»"); +} + +function scan(file: string, source: string): Offence[] { + const offences: Offence[] = []; + for (const block of testBlocks(source)) { + const body = source.slice(block.start, block.end); + GOTO.lastIndex = 0; + for (let g = GOTO.exec(body); g !== null; g = GOTO.exec(body)) { + const after = body.slice(g.index); + const read = ONE_SHOT_READ.exec(after); + if (!read) continue; + const between = stripSwallowedWaits(after.slice(0, read.index)); + if (GATE.test(between)) continue; + offences.push({ + file, + testName: block.name, + line: lineOf(source, block.start + g.index), + }); + break; + } + } + return offences; +} + +describe("e2e specs gate their measurements on the element, not the network", () => { + const specs = readdirSync(E2E_DIR).filter((f) => f.endsWith(".spec.ts")); + + it("finds spec files to check (the guard must not pass vacuously)", () => { + expect(specs.length).toBeGreaterThan(10); + }); + + it("has no ungated goto → one-shot DOM read outside the allowlist", () => { + const offences: Offence[] = []; + for (const spec of specs) { + const source = readFileSync(join(E2E_DIR, spec), "utf8"); + offences.push(...scan(spec, source)); + } + + const unexplained = offences.filter( + (o) => + !ALLOWLIST.some( + (a) => a.file === o.file && o.testName.includes(a.test), + ), + ); + + if (unexplained.length > 0) { + throw new Error( + `${unexplained.length} e2e test(s) navigate and then read the DOM once, with nothing in between that waits for the element:\n\n` + + unexplained + .map((o) => ` ${o.file}:${o.line} — "${o.testName}"`) + .join("\n") + + "\n\nAn idle network does not mean React has rendered. Gate on the element the " + + "measurement is about (await expect(locator).toBeVisible(), or settleBeforeMeasure " + + "from e2e/utils/settle.ts). If the read genuinely does not need a gate, add it to " + + "ALLOWLIST in this file with the reason.", + ); + } + }); + + it("carries no allowlist entry for a spec file that no longer exists", () => { + for (const entry of ALLOWLIST) { + expect(specs, `stale allowlist entry: ${entry.file}`).toContain( + entry.file, + ); + } + }); + + it("keeps the allowlist small enough to still be an inventory of exceptions", () => { + expect(ALLOWLIST.length).toBeLessThanOrEqual(15); + }); +}); From d11019aebc83ea548bf4fe7ab94fa0f8734617bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:00:43 +0200 Subject: [PATCH 02/39] feat(ai): add the storage for an OpenAI-compatible gateway provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three dedicated User columns — base URL, optional bearer, model — for a provider whose endpoint the user chooses. They are separate from the OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must share no column with a user-supplied host, and a gateway must never receive the key issued for OpenAI. The bearer joins the encrypted-column registry and the rotation script; the three columns join the account-wipe reset set. --- .../migration.sql | 17 +++++++++++++++++ prisma/schema.prisma | 12 ++++++++++++ scripts/rotate-encryption-key.ts | 5 ++++- src/lib/crypto/encrypted-columns.ts | 4 ++++ src/lib/data-wipe/wipe-plan.ts | 3 +++ 5 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0280_openai_compatible_provider/migration.sql diff --git a/prisma/migrations/0280_openai_compatible_provider/migration.sql b/prisma/migrations/0280_openai_compatible_provider/migration.sql new file mode 100644 index 000000000..552a1d2e2 --- /dev/null +++ b/prisma/migrations/0280_openai_compatible_provider/migration.sql @@ -0,0 +1,17 @@ +-- The OpenAI-compatible gateway provider (#470): a user-configured endpoint +-- that speaks `/v1/chat/completions` — LiteLLM, OpenRouter, vLLM, or anything +-- else on that wire — with its own base URL, its own optional bearer, and its +-- own model. +-- +-- Three dedicated columns instead of reusing `ai_base_url` / the OpenAI key. +-- The OpenAI arm pins `api.openai.com` on purpose, so it must have no column +-- in common with a provider whose host the user chooses; and the gateway must +-- never receive the key the user issued for OpenAI. Separating the storage +-- makes both properties structural rather than a comment someone can delete. +-- +-- All three are nullable and additive: an install that never configures the +-- provider carries three NULLs and resolves exactly as before. + +ALTER TABLE "users" ADD COLUMN "ai_compat_base_url" TEXT; +ALTER TABLE "users" ADD COLUMN "ai_compat_key_encrypted" TEXT; +ALTER TABLE "users" ADD COLUMN "ai_compat_model" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23ef6ad6b..367884b30 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -311,6 +311,18 @@ model User { // before storage. aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted") + // v1.33.1 (#470) — the OpenAI-compatible gateway provider (LiteLLM, + // OpenRouter, vLLM, any endpoint speaking `/v1/chat/completions`). + // Three DEDICATED columns rather than a reuse of `aiBaseUrl` / + // `aiOpenaiKeyEncrypted`: the OpenAI arm pins `api.openai.com` and must + // never be able to read a user-supplied host, and the gateway credential + // must never be the OpenAI key. Separate columns make that structural + // instead of a comment. The base URL passes the same SSRF floor as the + // Local provider (`isPublicUrl` unless the operator allowlisted the host). + aiCompatBaseUrl String? @map("ai_compat_base_url") + aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted") + aiCompatModel String? @map("ai_compat_model") + // v1.4.16 phase B5b — ordered fallback chain for multi-provider AI // redundancy. Each entry is `{ providerType, priority, enabled }`, // sorted by ascending priority at runtime. Credentials live in the diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 8fefe6ba2..06b10a0c5 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -170,7 +170,8 @@ async function main() { // "ouraClientSecretEncrypted" "stravaClientIdEncrypted" // "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted" // "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted" - // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted" + // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "aiCompatKeyEncrypted" + // "insuranceNumberEncrypted" const userFields = [ "codexAccessTokenEncrypted", "codexRefreshTokenEncrypted", @@ -200,6 +201,8 @@ async function main() { "aiAnthropicKeyEncrypted", "aiLocalKeyEncrypted", "aiOpenaiKeyEncrypted", + // v1.33.1 (#470) — the OpenAI-compatible gateway's optional bearer. + "aiCompatKeyEncrypted", "insuranceNumberEncrypted", // v1.23 — TOTP shared secret (second factor). "totpSecretEncrypted", diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 276062a1b..c8e9d5915 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -81,6 +81,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" }, { model: "User", field: "aiLocalKeyEncrypted", kind: "string" }, { model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" }, + // v1.33.1 (#470) — the OpenAI-compatible gateway provider's optional bearer. + // A dedicated column, never the OpenAI key: the gateway host is user-chosen, + // so the credential that reaches it must be one the user issued for it. + { model: "User", field: "aiCompatKeyEncrypted", kind: "string" }, { model: "User", field: "insuranceNumberEncrypted", kind: "string" }, // v1.23 — TOTP shared secret (second factor). String ciphertext like the // other User credential columns. diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 7ad5d753b..2004a7a35 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -262,6 +262,9 @@ export const USER_RESET = { aiAnthropicKeyEncrypted: null, aiLocalKeyEncrypted: null, aiOpenaiKeyEncrypted: null, + aiCompatBaseUrl: null, + aiCompatKeyEncrypted: null, + aiCompatModel: null, aiProviderChain: Prisma.DbNull, aiResponseTimeoutSeconds: null, disableCoach: false, From 0c7059804bb9b0b4a0d1a2865bef11f922cc7286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:07:53 +0200 Subject: [PATCH 03/39] feat(ai): resolve an OpenAI-compatible gateway as its own provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chain gains a seventh entry. It builds the OpenAI client against the base URL the user saved, with the bearer they saved for it and the model they named, and it reads none of the OpenAI columns — the pinned api.openai.com arm stays pinned. Host policy is the Local provider's, unchanged: a public host always, a private one only where the operator allowlisted it, which is what a LAN LiteLLM or vLLM needs. Egress keeps riding safeFetch. The JSON-mode dialect the Local client learns per endpoint moves into a shared module and now serves both clients, so a gateway that rejects response_format is detected once and retried without it. The entry stays out of the default chain: it can only resolve against a base URL somebody configured. --- src/app/api/ai/test/route.ts | 14 ++- src/app/api/user/ai-provider/route.ts | 59 +++++++++- src/lib/ai/coach/budget.ts | 3 +- src/lib/ai/json-dialect.ts | 72 ++++++++++++ src/lib/ai/local-client.ts | 68 ++++------- src/lib/ai/openai-client.ts | 113 ++++++++++++++++-- src/lib/ai/provider-chain.ts | 14 +++ src/lib/ai/provider.ts | 155 +++++++++++++++++++++++++ src/lib/ai/types.ts | 7 +- src/lib/ai/vision-capability.ts | 7 ++ src/lib/documents/provider-order.ts | 8 +- src/lib/jobs/coach-nudge.ts | 3 + src/lib/jobs/status-cron-candidates.ts | 3 + 13 files changed, 462 insertions(+), 64 deletions(-) create mode 100644 src/lib/ai/json-dialect.ts diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index cd7e8a12b..b1b43b522 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -42,7 +42,13 @@ export const dynamic = "force-dynamic"; const overrideSchema = z .object({ provider: z - .enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]) + .enum([ + "OPENAI", + "ANTHROPIC", + "LOCAL", + "OPENAI_COMPATIBLE", + "CHATGPT_OAUTH", + ]) .optional() .nullable(), model: z.string().min(1).max(120).optional().nullable(), @@ -50,6 +56,12 @@ const overrideSchema = z anthropicKey: z.string().min(1).max(500).optional().nullable(), localKey: z.string().min(1).max(500).optional().nullable(), openaiKey: z.string().min(1).max(500).optional().nullable(), + // v1.33.1 (#470) — the OpenAI-compatible gateway's own fields, kept + // separate from `baseUrl` / `openaiKey` so testing an unsaved gateway + // config can never reach the pinned OpenAI arm and vice versa. + compatBaseUrl: z.string().url().max(2048).optional().nullable(), + compatKey: z.string().min(1).max(500).optional().nullable(), + compatModel: z.string().min(1).max(120).optional().nullable(), }) .strict(); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 6f654ae14..4fc45e32a 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -10,7 +10,15 @@ import { annotate } from "@/lib/logging/context"; export const dynamic = "force-dynamic"; -const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]); +const ALLOWED = new Set([ + "OPENAI", + "ANTHROPIC", + "LOCAL", + // v1.33.1 (#470) — the OpenAI-compatible gateway (LiteLLM / OpenRouter / + // vLLM). Its base URL, key and model live in their own three columns. + "OPENAI_COMPATIBLE", + "CHATGPT_OAUTH", +]); export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -25,6 +33,10 @@ export const GET = apiHandler(async () => { aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + // v1.33.1 (#470) — the OpenAI-compatible gateway. + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, // v1.22 (#89) aiResponseTimeoutSeconds: true, }, @@ -52,6 +64,11 @@ export const GET = apiHandler(async () => { openaiKeyPreview: u?.aiOpenaiKeyEncrypted ? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}` : null, + // v1.33.1 (#470) — the gateway's own configuration. The key is reported + // as presence only, like every other credential on this surface. + compatBaseUrl: u?.aiCompatBaseUrl ?? null, + compatModel: u?.aiCompatModel ?? null, + hasCompatKey: Boolean(u?.aiCompatKeyEncrypted), // v1.22 (#89) — per-user response timeout, in seconds (null = default). responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null, }); @@ -112,6 +129,46 @@ export const PATCH = apiHandler(async (request: NextRequest) => { } } + // ── v1.33.1 (#470) — the OpenAI-compatible gateway's three fields ── + // Deliberately a separate block from `baseUrl` above: that column is shared + // with LOCAL and is cleared when the provider switches away from it. This + // one belongs to a single provider and to no other, which is what keeps the + // OpenAI key from ever being sendable to a user-supplied host. + if (body.compatBaseUrl !== undefined) { + if (body.compatBaseUrl === null || body.compatBaseUrl === "") { + updates.aiCompatBaseUrl = null; + } else if (typeof body.compatBaseUrl === "string") { + const trimmed = body.compatBaseUrl.trim(); + // Same SSRF floor as the Local provider: a public host always, a + // private one only when the operator allowlisted it. Gateways on a LAN + // are the normal case for LiteLLM / vLLM, so the escape hatch matters + // here as much as it does for Ollama. + if (!isLocalAiHostAllowed(trimmed) && !isPublicUrl(trimmed)) { + return apiError( + "Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. litellm.lan) or to true for any private host (intended for a self-hosted gateway).", + 422, + ); + } + updates.aiCompatBaseUrl = trimmed; + } + } + + if (body.compatModel !== undefined) { + if (body.compatModel === null || body.compatModel === "") { + updates.aiCompatModel = null; + } else if (typeof body.compatModel === "string") { + updates.aiCompatModel = body.compatModel.trim() || null; + } + } + + if (body.compatKey !== undefined) { + if (body.compatKey === null || body.compatKey === "") { + updates.aiCompatKeyEncrypted = null; + } else if (typeof body.compatKey === "string") { + updates.aiCompatKeyEncrypted = encrypt(body.compatKey.trim()); + } + } + if (body.anthropicKey !== undefined) { if (body.anthropicKey === null || body.anthropicKey === "") { updates.aiAnthropicKeyEncrypted = null; diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 21aa7d2c5..35c026222 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -55,7 +55,8 @@ export const USER_PLAN_CAP = 2_000_000; * primary provider is `admin-openai` (the operator's shared API key) OR * `admin-codex` (the operator's shared ChatGPT-subscription account) — both * drain the operator's resources, so the operator-cost cap applies. Every other - * primary (`codex` / `openai` / `anthropic` / `local`) is the user's own + * primary (`codex` / `openai` / `anthropic` / `local` / `openai-compatible`) + * is the user's own * egress, so the generous user-plan cap applies. An empty chain (no provider * resolved) defaults to the operator cap — the conservative side. */ diff --git a/src/lib/ai/json-dialect.ts b/src/lib/ai/json-dialect.ts new file mode 100644 index 000000000..f63a5a481 --- /dev/null +++ b/src/lib/ai/json-dialect.ts @@ -0,0 +1,72 @@ +/** + * Per-endpoint JSON-mode dialect, learned at runtime. + * + * Landed for the Local client in v1.28.28 (#470) and shared with the + * OpenAI-compatible gateway provider in v1.33.1 — both talk to endpoints + * HealthLog does not control, and both need the same answer to the same + * question: does this endpoint accept the standard + * `response_format: { type: "json_object" }` field? + * + * - `"response_format"` (the default): send the standard OpenAI field. + * Strict OpenAI-compatible gateways (LiteLLM, OpenRouter, vLLM) and + * Ollama's own `/v1` shim all accept it. + * - `"none"`: send no structured-output flag at all. Whatever JSON steering + * the caller already put in the prompt is the only steering left. + * (Ollama's NATIVE top-level `format: "json"` never belonged on the + * OpenAI-compatible `/chat/completions` wire — it 400s on strict + * gateways — so the fallback stays minimal rather than resurrecting it.) + * + * The dialect is cached per base URL for the process lifetime: the first + * JSON-mode request that 4xxes with a body referencing `response_format` or + * an unknown parameter flips that endpoint to `"none"` and is retried once + * without the flag. A successful flagged request pins `"response_format"` so + * a later transient 4xx cannot silently degrade the endpoint. + * + * The cache is keyed by base URL, not by provider, so two providers pointed + * at the same gateway learn once between them. + */ + +export type JsonModeDialect = "response_format" | "none"; + +const dialectByBaseUrl = new Map(); + +/** The learned dialect for an endpoint, defaulting to the standard field. */ +export function jsonModeDialectFor(baseUrl: string): JsonModeDialect { + return dialectByBaseUrl.get(baseUrl) ?? "response_format"; +} + +/** True once an endpoint's dialect has actually been observed. */ +export function hasLearnedJsonModeDialect(baseUrl: string): boolean { + return dialectByBaseUrl.has(baseUrl); +} + +/** Record what an endpoint answered to. */ +export function rememberJsonModeDialect( + baseUrl: string, + dialect: JsonModeDialect, +): void { + dialectByBaseUrl.set(baseUrl, dialect); +} + +/** Test hook — clears the learned per-endpoint dialect cache. */ +export function resetJsonModeDialectCache(): void { + dialectByBaseUrl.clear(); +} + +/** + * True when a 4xx error body reads as "this endpoint rejects the + * `response_format` field" — either it names the field outright or it + * complains about an unknown/unexpected parameter. + */ +export function isResponseFormatRejection( + status: number, + bodyExcerpt: string, +): boolean { + if (status < 400 || status >= 500) return false; + return ( + /response_format/i.test(bodyExcerpt) || + /(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test( + bodyExcerpt, + ) + ); +} diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 91fb5c14a..2da3d3119 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -8,6 +8,14 @@ import type { CompletionResult, } from "./types"; import { buildOpenAIMessages, mapFinishReason } from "./openai-wire"; +import { + hasLearnedJsonModeDialect, + isResponseFormatRejection, + jsonModeDialectFor, + rememberJsonModeDialect, + resetJsonModeDialectCache, + type JsonModeDialect, +} from "./json-dialect"; interface LocalClientConfig { apiKey?: string | null; @@ -20,51 +28,18 @@ const STRICT_JSON_PREFIX = /** * v1.28.28 (#470) — per-endpoint JSON-mode dialect, learned at runtime. - * - * - `"response_format"` (the default): send the standard OpenAI - * `response_format: { type: "json_object" }`. Strict OpenAI-compatible - * gateways (LiteLLM, OpenRouter, vLLM) and Ollama's own `/v1` shim all - * accept it. - * - `"none"`: send no structured-output flag at all. The strict-JSON - * instruction prepended to the first user turn remains the only JSON - * steering — which is what this client shipped for years and what local - * model templates respect. (Ollama's NATIVE `format: "json"` field never - * belonged on the OpenAI-compatible `/chat/completions` wire; it was - * tolerated by Ollama but 400s on strict gateways, so the fallback stays - * minimal instead of resurrecting it.) - * - * The dialect is cached per baseUrl for the process lifetime: the first - * JSON-mode request that 4xxes with an error body referencing - * `response_format` / an unknown parameter flips the endpoint to `"none"` - * and is retried once without the flag (self-healing, mirroring the - * google-health dateFilter self-heal posture). Unrelated 4xx errors are - * NOT retried — they surface as the structured error they always were. + * v1.33.1 — the machinery moved to `json-dialect.ts` when the + * OpenAI-compatible gateway provider needed the same learning; the two + * clients share one cache keyed by base URL. The names below stay exported + * from here because this client's tests and call sites were the first + * consumers. */ -type LocalJsonDialect = "response_format" | "none"; -const jsonDialectByBaseUrl = new Map(); +type LocalJsonDialect = JsonModeDialect; /** Test hook — clears the learned per-endpoint dialect cache. */ -export function resetLocalJsonDialectCache(): void { - jsonDialectByBaseUrl.clear(); -} +export const resetLocalJsonDialectCache = resetJsonModeDialectCache; -/** - * True when a 4xx error body reads as "this endpoint rejects the - * `response_format` field" — either it names the field outright or it - * complains about an unknown/unexpected parameter. - */ -export function isResponseFormatRejection( - status: number, - bodyExcerpt: string, -): boolean { - if (status < 400 || status >= 500) return false; - return ( - /response_format/i.test(bodyExcerpt) || - /(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test( - bodyExcerpt, - ) - ); -} +export { isResponseFormatRejection }; /** * v1.22 (#89) — absolute backstop for the streaming path. The real ceiling is @@ -141,9 +116,8 @@ export class LocalOpenAICompatibleClient implements AIProvider { jsonDialect: LocalJsonDialect; } { const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; - // v1.28.28 (#470) — per-endpoint JSON dialect (see LocalJsonDialect). - const jsonDialect = - jsonDialectByBaseUrl.get(this.config.baseUrl) ?? "response_format"; + // v1.28.28 (#470) — per-endpoint JSON dialect (see json-dialect.ts). + const jsonDialect = jsonModeDialectFor(this.config.baseUrl); const headers: Record = { "Content-Type": "application/json", @@ -255,7 +229,7 @@ export class LocalOpenAICompatibleClient implements AIProvider { jsonDialect === "response_format" && isResponseFormatRejection(res.status, bodyExcerpt) ) { - jsonDialectByBaseUrl.set(this.config.baseUrl, "none"); + rememberJsonModeDialect(this.config.baseUrl, "none"); annotate({ action: { name: "ai.local.jsonDialect" }, meta: { dialect: "none", httpStatus: res.status }, @@ -279,9 +253,9 @@ export class LocalOpenAICompatibleClient implements AIProvider { if ( params.responseFormat === "json" && jsonDialect === "response_format" && - !jsonDialectByBaseUrl.has(this.config.baseUrl) + !hasLearnedJsonModeDialect(this.config.baseUrl) ) { - jsonDialectByBaseUrl.set(this.config.baseUrl, "response_format"); + rememberJsonModeDialect(this.config.baseUrl, "response_format"); annotate({ action: { name: "ai.local.jsonDialect" }, meta: { dialect: "response_format" }, diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index eb1e140ce..6cccde128 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -1,6 +1,14 @@ import { safeFetch } from "@/lib/safe-fetch"; +import { annotate } from "@/lib/logging/context"; import type { AIProvider, CompletionParams, CompletionResult } from "./types"; import { selectOpenAIChatCompletionsCapabilities } from "./openai-capabilities"; +import { + hasLearnedJsonModeDialect, + isResponseFormatRejection, + jsonModeDialectFor, + rememberJsonModeDialect, +} from "./json-dialect"; +import { requirePublicHostFor } from "./local-host-allowlist"; import { buildOpenAIMessages, buildOpenAITools, @@ -11,6 +19,11 @@ import { } from "./openai-wire"; interface OpenAIClientConfig { + /** + * Bearer credential. Empty string means "no bearer" — only the + * `openai-compatible` tag ever constructs that way (a LAN gateway with no + * auth); every OpenAI-keyed call site checks the key's presence first. + */ apiKey: string; model: string; baseUrl: string; @@ -20,11 +33,18 @@ interface OpenAIClientConfig { * was obtained via the token-exchange grant against a ChatGPT * subscription — for billing and observability we want that path * to log as "codex" instead. + * + * v1.33.1 (#470) — `openai-compatible` is the user-configured gateway + * (LiteLLM / OpenRouter / vLLM). It is the ONLY tag whose `baseUrl` is not + * a HealthLog-pinned constant, and the two behaviours below hang off it: + * the operator's private-host allowlist may apply, and the JSON-mode + * dialect is learned per endpoint instead of assumed. Every other tag + * keeps the pinned `api.openai.com` posture unchanged. */ - providerType?: "admin-key" | "codex"; + providerType?: OpenAIProviderType; } -type OpenAIProviderType = "admin-key" | "codex"; +type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible"; export class OpenAIClient implements AIProvider { readonly type: OpenAIProviderType; @@ -35,6 +55,15 @@ export class OpenAIClient implements AIProvider { this.type = config.providerType ?? "admin-key"; } + /** + * True only for the user-configured gateway. Read it as "the base URL came + * from a person, not from this repository" — every behaviour that must not + * change for `api.openai.com` is gated on it. + */ + private get isGateway(): boolean { + return this.type === "openai-compatible"; + } + async generateCompletion( params: CompletionParams, ): Promise { @@ -61,6 +90,14 @@ export class OpenAIClient implements AIProvider { // therefore stay out of JSON mode, and every tool round is non-JSON by // construction. Insight/extraction callers opt in with `responseFormat:"json"`. const useJsonFormat = params.responseFormat === "json" && !hasTools; + // v1.33.1 (#470) — a gateway may reject the standard `response_format` + // field. Learn that per endpoint (shared cache with the Local client) and + // retry once without the flag below. `api.openai.com` defines the field, + // so the pinned tags never consult the dialect and never retry. + const jsonDialect = this.isGateway + ? jsonModeDialectFor(this.config.baseUrl) + : "response_format"; + const sendJsonFormat = useJsonFormat && jsonDialect === "response_format"; const capabilities = selectOpenAIChatCompletionsCapabilities( this.config.baseUrl, this.config.model, @@ -74,19 +111,27 @@ export class OpenAIClient implements AIProvider { } : { [capabilities.tokenBudgetField]: tokenBudget }; + // A gateway that needs no bearer (a LAN LiteLLM without a master key) + // gets no `Authorization` header at all rather than an empty one — an + // empty bearer is a 401 on some proxies. Every OpenAI-keyed construction + // site checks the key's presence, so this branch is gateway-only in + // practice. + const headers: Record = { + "Content-Type": "application/json", + }; + const trimmedKey = this.config.apiKey?.trim(); + if (trimmedKey) headers.Authorization = `Bearer ${trimmedKey}`; + const res = await safeFetch( url, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.config.apiKey}`, - }, + headers, body: JSON.stringify({ model: this.config.model, messages, ...capabilityParams, - ...(useJsonFormat + ...(sendJsonFormat ? { response_format: { type: "json_object" } } : {}), ...(tools ? { tools } : {}), @@ -101,9 +146,16 @@ export class OpenAIClient implements AIProvider { // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so // a mid-generation abort tears the upstream call down early. // v1.21.5 — honour the caller's per-request timeout override; default 60 s. + // v1.33.1 (#470) — the gateway tag reuses the Local provider's host + // policy verbatim: public hosts always; a private host only when the + // operator allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS. Every other + // tag stays pinned to `requirePublicHost: true` — `api.openai.com` is + // public and there is nothing to opt into. { timeoutMs: params.timeoutMs ?? 60_000, - requirePublicHost: true, + requirePublicHost: this.isGateway + ? requirePublicHostFor(this.config.baseUrl) + : true, signal: params.signal, }, ); @@ -118,19 +170,54 @@ export class OpenAIClient implements AIProvider { .slice(0, 500) .replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***") .replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***"); - const err = new Error(`OpenAI request failed (${res.status})`); + // v1.33.1 (#470) — dialect self-heal, gateway only. A 4xx whose body + // names `response_format` (or an unknown parameter) means this endpoint + // rejects the standard JSON flag: learn the no-flag dialect for the base + // URL and retry ONCE without it. The recursion terminates because the + // cached dialect is now "none". Unrelated 4xx/5xx errors are not retried + // and surface as the structured error they always were. + if ( + this.isGateway && + sendJsonFormat && + isResponseFormatRejection(res.status, bodyExcerpt) + ) { + rememberJsonModeDialect(this.config.baseUrl, "none"); + annotate({ + action: { name: "ai.compat.jsonDialect" }, + meta: { dialect: "none", httpStatus: res.status }, + }); + return this.generateCompletion(params); + } + const err = new Error( + `${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} request failed (${res.status})`, + ); // Keep the body in a structured field rather than the message so // Error.message stays short and the excerpt lands in dedicated log // fields (bodyExcerpt) that can be filtered/truncated centrally. Object.assign(err, { httpStatus: res.status, - upstream: "openai", + upstream: this.isGateway ? "openai-compatible" : "openai", model: this.config.model, bodyExcerpt, }); throw err; } + // v1.33.1 (#470) — the standard `response_format` request succeeded: pin + // the dialect for this endpoint so a later transient 4xx can never + // silently degrade it. + if ( + this.isGateway && + sendJsonFormat && + !hasLearnedJsonModeDialect(this.config.baseUrl) + ) { + rememberJsonModeDialect(this.config.baseUrl, "response_format"); + annotate({ + action: { name: "ai.compat.jsonDialect" }, + meta: { dialect: "response_format" }, + }); + } + const json = (await res.json()) as OpenAIResponseJson; const choice = json.choices?.[0]; let content = choice?.message?.content; @@ -163,11 +250,13 @@ export class OpenAIClient implements AIProvider { // observability. The cascade is unchanged: `isHardProviderFailure` // already treats `status <= 0` as a hard failure and walks to the next // provider. - const err = new Error("OpenAI returned empty content"); + const err = new Error( + `${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} returned empty content`, + ); Object.assign(err, { httpStatus: 0, kind: "empty_response", - upstream: "openai", + upstream: this.isGateway ? "openai-compatible" : "openai", }); throw err; } diff --git a/src/lib/ai/provider-chain.ts b/src/lib/ai/provider-chain.ts index 977646984..6a92ad27a 100644 --- a/src/lib/ai/provider-chain.ts +++ b/src/lib/ai/provider-chain.ts @@ -32,6 +32,15 @@ * (see `resolveProviderChain`), so a persisted `admin-codex` entry never * resolves on its own. * + * `openai-compatible` (v1.33.1, #470) is the user-configured gateway — + * LiteLLM, OpenRouter, vLLM, or any other endpoint on the + * `/v1/chat/completions` wire. It is offered in the chain editor but is NOT + * part of `PROVIDER_CHAIN_DEFAULT`: it resolves only against the base URL the + * user saved, so putting it in the default chain would put an entry there + * that can never resolve for anyone who has not configured it. It reads its + * own three columns and never the OpenAI ones — the OpenAI arm's pinned base + * URL stays pinned. + * * Distinct from `ProviderType` (in `types.ts`) because that enum tags * the **runtime** provider instance — which has no notion of "admin * OpenAI" vs "user OpenAI" at the wire level (both are @@ -43,6 +52,7 @@ export const PROVIDER_CHAIN_TYPES = [ "openai", "anthropic", "local", + "openai-compatible", "admin-openai", "admin-codex", ] as const; @@ -65,6 +75,10 @@ export interface ProviderChainEntry { * 4. local — self-hosted Ollama / LM Studio. * 5. admin-openai — operator's shared key, last-ditch so a user with * no personal config still sees insights. + * + * `openai-compatible` is deliberately absent: it resolves only against a + * base URL the user saved, so a default entry would be dead weight for + * everyone who never configures one. Users add it in the chain editor. */ export const PROVIDER_CHAIN_DEFAULT: readonly ProviderChainEntry[] = [ { providerType: "codex", priority: 1, enabled: true }, diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 4b4552a73..a36cf735b 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -36,8 +36,48 @@ type UserAIRow = { aiAnthropicKeyEncrypted: string | null; aiLocalKeyEncrypted: string | null; aiOpenaiKeyEncrypted: string | null; + aiCompatBaseUrl: string | null; + aiCompatKeyEncrypted: string | null; + aiCompatModel: string | null; }; +/** + * v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's + * three dedicated columns. The signature names every column this provider is + * allowed to read: the OpenAI key and `aiBaseUrl` are absent on purpose, so a + * future edit that wants to hand it either has to widen this shape first and + * answer for it. + * + * Returns null when the gateway cannot be addressed — no base URL, or no + * model anywhere. An unconfigured entry is SKIPPED by the chain runner rather + * than erroring, exactly like an `anthropic` entry with no key. + * + * Model resolution: `aiCompatModel` is the override; absent, the shared + * `aiModel` applies (a gateway usually routes the same model name the user + * already types for their other providers). Nothing is invented — with + * neither set there is no model to send, so the entry does not resolve. + * + * The key is OPTIONAL: a LAN LiteLLM without a master key needs no bearer, + * and the client omits the header entirely for an empty string. The base URL + * passed the SSRF floor at write time (`/api/user/ai-provider`); the client + * re-applies the Local provider's host policy at call time. + */ +function buildCompatProvider(row: { + aiModel: string | null; + aiCompatBaseUrl: string | null; + aiCompatKeyEncrypted: string | null; + aiCompatModel: string | null; +}): AIProvider | null { + const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null; + if (!row.aiCompatBaseUrl || !model) return null; + return new OpenAIClient({ + apiKey: row.aiCompatKeyEncrypted ? decrypt(row.aiCompatKeyEncrypted) : "", + model, + baseUrl: row.aiCompatBaseUrl, + providerType: "openai-compatible", + }); +} + /** * Build a provider from a user-level config row. Returns null if the row does * not select a usable per-user provider (caller falls back to admin/codex). @@ -68,6 +108,11 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { baseUrl: row.aiBaseUrl, }); } + case "OPENAI_COMPATIBLE": { + // v1.33.1 (#470) — the gateway provider. Reads its own three columns + // and nothing else; the OpenAI arm below stays pinned. + return buildCompatProvider(row); + } case "OPENAI": { // v1.4.3: user-level OpenAI key gets first crack — only fall back // to the admin key if the user hasn't supplied their own. The @@ -364,6 +409,9 @@ export async function resolveProvider(userId: string): Promise { aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, }, }); @@ -420,6 +468,9 @@ export async function resolveProviderChain( aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, aiProviderChain: true, useCentralCodex: true, }, @@ -468,6 +519,9 @@ export interface ProviderCredentialRow { aiLocalKeyEncrypted: string | null; aiOpenaiKeyEncrypted: string | null; aiBaseUrl: string | null; + aiModel: string | null; + aiCompatBaseUrl: string | null; + aiCompatModel: string | null; codexConnectionStatus: string | null; codexAccessTokenEncrypted: string | null; codexRefreshTokenEncrypted: string | null; @@ -514,6 +568,13 @@ export function userRowHasProviderCredential( case "local": if (row.aiBaseUrl) return true; break; + case "openai-compatible": + // Presence mirrors `buildCompatProvider`: a gateway needs an address + // AND a model name; the key is optional. + if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) { + return true; + } + break; case "admin-openai": if (adminKeyConfigured) return true; break; @@ -525,6 +586,13 @@ export function userRowHasProviderCredential( const choice = row.aiProvider?.toUpperCase(); if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return true; if (choice === "LOCAL" && row.aiBaseUrl) return true; + if ( + choice === "OPENAI_COMPATIBLE" && + row.aiCompatBaseUrl && + (row.aiCompatModel || row.aiModel) + ) { + return true; + } if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return true; if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return true; return adminKeyConfigured; @@ -549,6 +617,9 @@ export async function hasAnyConfiguredProvider( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiCompatBaseUrl: true, + aiCompatModel: true, + aiModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true, @@ -637,6 +708,14 @@ function resolveManagedByFromRow( case "local": if (row.aiBaseUrl) return "local"; break; + case "openai-compatible": + // The gateway is the user's own egress to an endpoint they chose, + // like a BYO key — not the operator's, and not presumed on-host + // (LiteLLM / OpenRouter are usually remote). + if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) { + return "user"; + } + break; case "admin-openai": if (adminKeyConfigured) return "server"; break; @@ -648,6 +727,13 @@ function resolveManagedByFromRow( const choice = row.aiProvider?.toUpperCase(); if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return "user"; if (choice === "LOCAL" && row.aiBaseUrl) return "local"; + if ( + choice === "OPENAI_COMPATIBLE" && + row.aiCompatBaseUrl && + (row.aiCompatModel || row.aiModel) + ) { + return "user"; + } if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return "user"; if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return "user"; return adminKeyConfigured ? "server" : null; @@ -672,6 +758,9 @@ export async function resolveProviderAvailability( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiCompatBaseUrl: true, + aiCompatModel: true, + aiModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true, @@ -715,6 +804,9 @@ async function resolveProviderForType( aiOpenaiKeyEncrypted: string | null; aiBaseUrl: string | null; aiModel: string | null; + aiCompatBaseUrl: string | null; + aiCompatKeyEncrypted: string | null; + aiCompatModel: string | null; } | null; }, ): Promise { @@ -749,6 +841,13 @@ async function resolveProviderForType( baseUrl: ctx.userRow.aiBaseUrl, }); } + case "openai-compatible": { + // v1.33.1 (#470) — the user's gateway. Note what is NOT read here: + // `aiOpenaiKeyEncrypted` and `aiBaseUrl`. The gateway gets the + // credential the user issued for it, or none at all. + if (!ctx.userRow) return null; + return buildCompatProvider(ctx.userRow); + } case "admin-openai": { const admin = await resolveAdminProvider(); return admin.type === "none" ? null : admin; @@ -776,6 +875,13 @@ export type AITestOverride = { anthropicKey?: string | null; localKey?: string | null; openaiKey?: string | null; + // v1.33.1 (#470) — the gateway's own fields. Dedicated rather than shared + // with `baseUrl` / `openaiKey` for the same reason the columns are: the + // pinned OpenAI arm must not be able to see a user-supplied host, and the + // gateway must not be able to see the OpenAI key. + compatBaseUrl?: string | null; + compatKey?: string | null; + compatModel?: string | null; }; export class AITestConfigError extends Error { @@ -806,6 +912,9 @@ export async function resolveProviderForTest( aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, }, }); @@ -877,6 +986,52 @@ export async function resolveProviderForTest( baseUrl, }); } + case "OPENAI_COMPATIBLE": { + // v1.33.1 (#470) — the gateway. Reads `aiCompatBaseUrl`, never the + // merged `baseUrl` above (which can carry a stale LOCAL URL) and never + // the OpenAI key. Same host policy as LOCAL: public always, private + // only when the operator allowlisted it. + const compatBaseUrl = ( + override.compatBaseUrl ?? + stored?.aiCompatBaseUrl ?? + "" + ) + .toString() + .trim(); + if (!compatBaseUrl) { + throw new AITestConfigError( + 422, + "OpenAI-compatible provider requires a base URL", + ); + } + if (!isLocalAiHostAllowed(compatBaseUrl) && !isPublicUrl(compatBaseUrl)) { + throw new AITestConfigError( + 422, + "Base URL points to an internal/private host", + ); + } + const compatModel = + (override.compatModel ?? stored?.aiCompatModel ?? "") + .toString() + .trim() || model; + if (!compatModel) { + throw new AITestConfigError( + 422, + "OpenAI-compatible provider requires a model name", + ); + } + const apiKey = + override.compatKey?.trim() || + (stored?.aiCompatKeyEncrypted + ? decrypt(stored.aiCompatKeyEncrypted) + : ""); + return new OpenAIClient({ + apiKey, + model: compatModel, + baseUrl: compatBaseUrl, + providerType: "openai-compatible", + }); + } case "CHATGPT_OAUTH": { const codex = await resolveCodexProvider(userId); if (codex) return codex; diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index cb33c91aa..67681677f 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -102,8 +102,13 @@ export type InsightResult = z.infer; // ─── Provider Types ──────────────────────────────────────── +// v1.33.1 (#470) — `openai-compatible` is the user-configured gateway +// (LiteLLM / OpenRouter / vLLM). It runs the OpenAI client against a base URL +// the user owns, so it is a distinct runtime tag from `admin-key`: logs, +// analytics and the budget classifier must be able to tell "the operator's +// OpenAI key" from "an endpoint someone pointed us at". export type ProviderType = - "codex" | "admin-key" | "anthropic" | "local" | "none"; + "codex" | "admin-key" | "openai-compatible" | "anthropic" | "local" | "none"; export interface AIProvider { type: ProviderType; diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index 673e90c64..515e99184 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -114,6 +114,7 @@ export type VisionProviderType = | "admin-openai" | "admin-key" | "local" + | "openai-compatible" | "codex" | "admin-codex" | "none"; @@ -127,6 +128,11 @@ export type VisionProviderType = * configuring a self-hosted model and we cannot sniff * its capabilities; a non-vision local model surfaces * a clear extract failure rather than a silent gate. + * - openai-compatible → same trust-by-default: a gateway routes whatever + * model name its operator aliased, so the OpenAI + * allowlist would reject perfectly capable models + * (and accept incapable ones) on the strength of a + * string the gateway owner picked. * - codex → vision when the resolved codex slug is a * multimodal model (GPT-5.x / gpt-4o-class). Image * input rides the ChatGPT plan — no API key. A @@ -145,6 +151,7 @@ export function supportsVisionForConfig( case "admin-key": return model ? openaiSupportsVision(model) : false; case "local": + case "openai-compatible": return true; case "codex": // The operator-shared central Codex runs the same `codex/responses` wire on diff --git a/src/lib/documents/provider-order.ts b/src/lib/documents/provider-order.ts index 8d036425b..388e51edc 100644 --- a/src/lib/documents/provider-order.ts +++ b/src/lib/documents/provider-order.ts @@ -40,7 +40,8 @@ import type { /** * Preference rank for a provider when the payload is a DOCUMENT. Lower wins. * Local keeps the document on the machine (rank 0); BYOK no-train API keys are - * next (rank 1); the operator's shared no-train key follows (rank 2); the + * next (rank 1, including the user's own OpenAI-compatible gateway); the + * operator's shared no-train key follows (rank 2); the * ChatGPT-subscription OAuth paths are LAST (rank 3) — the user's own `codex` * AND the operator's shared `admin-codex`, both train on consumer content by * default and cannot be verified opted-out from here. @@ -52,6 +53,11 @@ function documentProviderRank(providerType: string): number { case "openai": case "anthropic": case "admin-key": + // The user's own gateway (LiteLLM / OpenRouter / vLLM) is a BYO endpoint + // under their own contract. It may well be on their own network, but + // HealthLog cannot tell that from the URL, so it ranks with the BYO API + // keys rather than with `local` — the conservative side. + case "openai-compatible": return 1; case "admin-openai": return 2; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index efed94e02..bd4f82344 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -814,6 +814,9 @@ export async function runCoachNudgeTick( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiModel: true, + aiCompatBaseUrl: true, + aiCompatModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true, diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts index 55a5d74eb..a32c41843 100644 --- a/src/lib/jobs/status-cron-candidates.ts +++ b/src/lib/jobs/status-cron-candidates.ts @@ -67,6 +67,9 @@ export async function findStatusCronCandidates( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiModel: true, + aiCompatBaseUrl: true, + aiCompatModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true, From ee620b270792aff5effcc62d8cbf857d1b78ee40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:12:17 +0200 Subject: [PATCH 04/39] feat(settings): configure the OpenAI-compatible gateway from Settings Its own form with its own three fields, its own entry in the provider dropdown and in the fallback-chain editor, and copy in six locales that says plainly where the OpenAI key does not go. The chain editor offers the entry but the default chain does not carry it: it resolves only against a base URL somebody saved. The read endpoint reports the gateway's base URL and model, and the bearer as presence only. OpenAPI regenerated. --- docs/api/openapi.yaml | 27 ++- messages/de.json | 7 + messages/en.json | 7 + messages/es.json | 7 + messages/fr.json | 7 + messages/it.json | 7 + messages/pl.json | 7 + .../settings/ai/ai-insights-card.tsx | 4 + .../settings/ai/compat-provider-form.tsx | 159 ++++++++++++++++++ src/components/settings/ai/shared.ts | 15 ++ src/lib/openapi/routes/profile.ts | 27 ++- 11 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 src/components/settings/ai/compat-provider-form.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 704a2ba43..8c734b58e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4477,10 +4477,10 @@ paths: tags: - Auth summary: Read the calling user's AI-provider configuration - description: "Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true - when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` - reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char - masked preview only." + description: "Returns the user's selected provider/model/baseUrl (plus the OpenAI-compatible gateway's own base URL and + model) and the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, + self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility + off these two fields. Key material is reported as presence + a 4-char masked preview only." responses: "200": description: Resolved AI-provider configuration. @@ -21942,6 +21942,7 @@ components: - OPENAI - ANTHROPIC - LOCAL + - OPENAI_COMPATIBLE - CHATGPT_OAUTH - type: "null" description: The user's selected provider, or null when none is set. @@ -21984,6 +21985,21 @@ components: - type: string - type: "null" description: "`...` + last 4 chars of the stored OpenAI key, or null." + compatBaseUrl: + anyOf: + - type: string + - type: "null" + description: "v1.33.1 (#470) — base URL of the OpenAI-compatible gateway (LiteLLM / OpenRouter / vLLM). Its own column: + the OpenAI provider stays pinned to api.openai.com and never reads it." + compatModel: + anyOf: + - type: string + - type: "null" + description: Model the gateway should route. Null falls back to `model`; with neither set the gateway entry does not + resolve. + hasCompatKey: + type: boolean + description: Whether a bearer is stored for the gateway. The bearer is optional — a gateway without auth needs none. responseTimeoutSeconds: anyOf: - type: integer @@ -22003,6 +22019,9 @@ components: - hasLocalKey - hasOpenaiKey - openaiKeyPreview + - compatBaseUrl + - compatModel + - hasCompatKey - responseTimeoutSeconds additionalProperties: false description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). diff --git a/messages/de.json b/messages/de.json index 5e38d38b1..6cacb7616 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4937,6 +4937,7 @@ "openai": "OpenAI (eigener Key)", "anthropic": "Anthropic (Claude)", "local": "Lokales Modell", + "openai-compatible": "OpenAI-kompatibles Gateway", "admin-openai": "Admin-OpenAI" }, "title": "Fallback-Kette", @@ -4963,8 +4964,14 @@ "openai": "OpenAI (eigener API-Key)", "anthropic": "Anthropic (Claude)", "local": "Lokales Modell (OpenAI-kompatibel)", + "openai-compatible": "OpenAI-kompatibles Gateway", "admin-openai": "Admin-OpenAI" }, + "compat": { + "description": "Ein vollwertiger Anbieter für jeden Endpunkt, der die OpenAI-API /v1/chat/completions spricht: LiteLLM, OpenRouter, vLLM oder ein anderes Gateway. Die Basis-URL endet auf /v1, der API-Key ist optional, und das Modell ist das, was dein Gateway weiterleitet. Dein OpenAI-Key erreicht diesen Endpunkt nie — dieser Anbieter hat einen eigenen.", + "keyLabel": "API-Key (optional)", + "modelHint": "Der Modellname, den dein Gateway erwartet. Bleibt das Feld leer, wird das für deine anderen Anbieter gespeicherte Modell verwendet; ist auch das leer, wird dieser Anbieter übersprungen." + }, "openai": { "modelSelect": "Modell", "modelOptionCustom": "Eigener Slug…", diff --git a/messages/en.json b/messages/en.json index 8f5a8b52b..f7e401020 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4937,6 +4937,7 @@ "openai": "OpenAI (your key)", "anthropic": "Anthropic (Claude)", "local": "Local model", + "openai-compatible": "OpenAI-compatible gateway", "admin-openai": "Admin OpenAI" }, "title": "Fallback chain", @@ -4963,8 +4964,14 @@ "openai": "OpenAI (your API key)", "anthropic": "Anthropic (Claude)", "local": "Local model (OpenAI-compatible)", + "openai-compatible": "OpenAI-compatible gateway", "admin-openai": "Admin-provided OpenAI" }, + "compat": { + "description": "A first-class provider for any endpoint that speaks the OpenAI /v1/chat/completions API: LiteLLM, OpenRouter, vLLM or another gateway. The base URL ends with /v1, the API key is optional, and the model is whatever your gateway routes. Your OpenAI key never reaches this endpoint — this provider has its own.", + "keyLabel": "API key (optional)", + "modelHint": "The model name your gateway expects. Left blank, the model saved for your other providers is used; with neither set, this provider is skipped." + }, "openai": { "modelSelect": "Model", "modelOptionCustom": "Custom slug…", diff --git a/messages/es.json b/messages/es.json index 7f2b2817c..a856d5850 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4937,6 +4937,7 @@ "openai": "OpenAI (tu clave)", "anthropic": "Anthropic (Claude)", "local": "Modelo local", + "openai-compatible": "Pasarela compatible con OpenAI", "admin-openai": "OpenAI de administrador" }, "title": "Cadena de respaldo", @@ -4963,8 +4964,14 @@ "openai": "OpenAI (tu clave de API)", "anthropic": "Anthropic (Claude)", "local": "Modelo local (compatible con OpenAI)", + "openai-compatible": "Pasarela compatible con OpenAI", "admin-openai": "OpenAI proporcionado por el administrador" }, + "compat": { + "description": "Un proveedor de pleno derecho para cualquier endpoint que hable la API /v1/chat/completions de OpenAI: LiteLLM, OpenRouter, vLLM u otra pasarela. La URL base termina en /v1, la clave de API es opcional y el modelo es el que enrute tu pasarela. Tu clave de OpenAI nunca llega a este endpoint: este proveedor tiene la suya.", + "keyLabel": "Clave de API (opcional)", + "modelHint": "El nombre de modelo que espera tu pasarela. Si lo dejas vacío, se usa el modelo guardado para tus otros proveedores; si tampoco hay uno, este proveedor se omite." + }, "openai": { "modelSelect": "Modelo", "modelOptionCustom": "Slug personalizado…", diff --git a/messages/fr.json b/messages/fr.json index 16e785bca..052b02a26 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4937,6 +4937,7 @@ "openai": "OpenAI (votre clé)", "anthropic": "Anthropic (Claude)", "local": "Modèle local", + "openai-compatible": "Passerelle compatible OpenAI", "admin-openai": "OpenAI administrateur" }, "title": "Chaîne de secours", @@ -4963,8 +4964,14 @@ "openai": "OpenAI (votre clé API)", "anthropic": "Anthropic (Claude)", "local": "Modèle local (compatible OpenAI)", + "openai-compatible": "Passerelle compatible OpenAI", "admin-openai": "OpenAI fourni par l’administrateur" }, + "compat": { + "description": "Un fournisseur à part entière pour tout point de terminaison qui parle l'API /v1/chat/completions d'OpenAI : LiteLLM, OpenRouter, vLLM ou une autre passerelle. L'URL de base se termine par /v1, la clé API est facultative et le modèle est celui que votre passerelle achemine. Votre clé OpenAI n'atteint jamais ce point de terminaison — ce fournisseur a la sienne.", + "keyLabel": "Clé API (facultative)", + "modelHint": "Le nom de modèle attendu par votre passerelle. Laissé vide, le modèle enregistré pour vos autres fournisseurs est utilisé ; si aucun n'est défini, ce fournisseur est ignoré." + }, "openai": { "modelSelect": "Modèle", "modelOptionCustom": "Slug personnalisé…", diff --git a/messages/it.json b/messages/it.json index c366d144c..deda44729 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4937,6 +4937,7 @@ "openai": "OpenAI (la tua chiave)", "anthropic": "Anthropic (Claude)", "local": "Modello locale", + "openai-compatible": "Gateway compatibile con OpenAI", "admin-openai": "OpenAI amministratore" }, "title": "Catena di fallback", @@ -4963,8 +4964,14 @@ "openai": "OpenAI (la tua chiave API)", "anthropic": "Anthropic (Claude)", "local": "Modello locale (compatibile con OpenAI)", + "openai-compatible": "Gateway compatibile con OpenAI", "admin-openai": "OpenAI fornito dall’amministratore" }, + "compat": { + "description": "Un provider a pieno titolo per qualsiasi endpoint che parli l'API /v1/chat/completions di OpenAI: LiteLLM, OpenRouter, vLLM o un altro gateway. L'URL di base termina con /v1, la chiave API è facoltativa e il modello è quello instradato dal gateway. La tua chiave OpenAI non raggiunge mai questo endpoint: questo provider ha la propria.", + "keyLabel": "Chiave API (facoltativa)", + "modelHint": "Il nome del modello che il gateway si aspetta. Se lasci il campo vuoto viene usato il modello salvato per gli altri provider; se non è impostato nessuno dei due, questo provider viene saltato." + }, "openai": { "modelSelect": "Modello", "modelOptionCustom": "Slug personalizzato…", diff --git a/messages/pl.json b/messages/pl.json index 72518273c..cb49beb28 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4937,6 +4937,7 @@ "openai": "OpenAI (Twój klucz)", "anthropic": "Anthropic (Claude)", "local": "Model lokalny", + "openai-compatible": "Brama zgodna z OpenAI", "admin-openai": "OpenAI administratora" }, "title": "Łańcuch zapasowy", @@ -4963,8 +4964,14 @@ "openai": "OpenAI (Twój klucz API)", "anthropic": "Anthropic (Claude)", "local": "Model lokalny (zgodny z OpenAI)", + "openai-compatible": "Brama zgodna z OpenAI", "admin-openai": "OpenAI udostępniony przez administratora" }, + "compat": { + "description": "Pełnoprawny dostawca dla każdego punktu końcowego mówiącego API /v1/chat/completions OpenAI: LiteLLM, OpenRouter, vLLM lub inna brama. Adres bazowy kończy się na /v1, klucz API jest opcjonalny, a model to ten, który kieruje twoja brama. Twój klucz OpenAI nigdy nie trafia do tego punktu końcowego — ten dostawca ma własny.", + "keyLabel": "Klucz API (opcjonalny)", + "modelHint": "Nazwa modelu, której oczekuje twoja brama. Pozostawione puste, użyty zostanie model zapisany dla pozostałych dostawców; gdy nie ustawiono żadnego, ten dostawca jest pomijany." + }, "openai": { "modelSelect": "Model", "modelOptionCustom": "Niestandardowy slug…", diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 47bcc65c5..b342015a6 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -35,6 +35,7 @@ import { AnthropicProviderForm } from "./anthropic-provider-form"; import { AutoReadCard } from "./auto-read-card"; import { CentralCodexSwitch } from "./central-codex-switch"; import { CodexProviderForm } from "./codex-provider-form"; +import { CompatProviderForm } from "./compat-provider-form"; import { FallbackChainCard } from "./fallback-chain-card"; import { LocalProviderForm } from "./local-provider-form"; import { OpenAIProviderForm } from "./openai-provider-form"; @@ -344,6 +345,9 @@ function ProviderConfigCard({ {provider === "local" && ( )} + {provider === "openai-compatible" && ( + + )} {provider === "admin-openai" && ( )} diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx new file mode 100644 index 000000000..a91e444a7 --- /dev/null +++ b/src/components/settings/ai/compat-provider-form.tsx @@ -0,0 +1,159 @@ +"use client"; + +/* ──────────────────────────────────────────────────────────────── + * OpenAI-compatible gateway form (#470) — base URL + optional key + + * model. Three fields of its own, written to three columns of its own: + * the OpenAI provider stays pinned to api.openai.com and cannot see any + * of them, and the gateway never sees the OpenAI key. + * ──────────────────────────────────────────────────────────────── */ + +import { useRef, useState, type FormEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Save } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { apiPatch } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared"; + +export function CompatProviderForm({ + userProvider, +}: { + userProvider: UserAIProvider | null | undefined; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [msg, setMsg] = useState(null); + const [ok, setOk] = useState(false); + const submitInFlightRef = useRef(false); + + // Seed from the server once per distinct persisted value, the same + // render-time pattern the sibling forms use (no setState-in-effect). + const seededKey = + userProvider != null + ? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}` + : null; + const [previousSeed, setPreviousSeed] = useState(null); + if (seededKey && seededKey !== previousSeed) { + setPreviousSeed(seededKey); + setBaseUrl(userProvider?.compatBaseUrl ?? ""); + setModel(userProvider?.compatModel ?? ""); + } + + const saveMutation = useMutation({ + mutationFn: async () => { + const body: Record = { + provider: uiToLegacyProviderEnum("openai-compatible"), + compatBaseUrl: baseUrl.trim() || null, + compatModel: model.trim() || null, + }; + if (apiKey.trim()) body.compatKey = apiKey.trim(); + await apiPatch("/api/user/ai-provider", body); + }, + onSuccess: () => { + setOk(true); + setMsg(t("settings.ai.saved")); + setApiKey(""); + queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + }, + onError: (e) => { + setOk(false); + setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + }, + onSettled: () => { + submitInFlightRef.current = false; + }, + }); + + function submit(event: FormEvent) { + event.preventDefault(); + if (submitInFlightRef.current || saveMutation.isPending) return; + submitInFlightRef.current = true; + saveMutation.mutate(); + } + + return ( +
+

+ {t("settings.ai.compat.description")} +

+
+ + setBaseUrl(e.target.value)} + placeholder="https://litellm.example.com/v1" + className="mt-1" + /> +
+
+ + setApiKey(e.target.value)} + placeholder={ + userProvider?.hasCompatKey ? t("settings.ai.savedShort") : "" + } + className="mt-1" + /> +
+
+ + setModel(e.target.value)} + placeholder="anthropic/claude-sonnet-4-6" + className="mt-1" + /> +

+ {t("settings.ai.compat.modelHint")} +

+
+ +
+ +
+ + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/ai/shared.ts b/src/components/settings/ai/shared.ts index e3b922b4c..70dea2ac3 100644 --- a/src/components/settings/ai/shared.ts +++ b/src/components/settings/ai/shared.ts @@ -32,6 +32,12 @@ export interface UserAIProvider { hasLocalKey: boolean; hasOpenaiKey: boolean; openaiKeyPreview: string | null; + // v1.33.1 (#470) — the OpenAI-compatible gateway's own configuration. Its + // own fields, because its own columns: the OpenAI provider is pinned to + // api.openai.com and shares nothing with a user-supplied host. + compatBaseUrl: string | null; + compatModel: string | null; + hasCompatKey: boolean; // v1.22 (#89) — per-user response timeout, in seconds (null = default). responseTimeoutSeconds: number | null; } @@ -46,6 +52,7 @@ export const PROVIDER_TYPES = [ "openai", "anthropic", "local", + "openai-compatible", "admin-openai", ] as const; export type ProviderType = (typeof PROVIDER_TYPES)[number]; @@ -65,6 +72,12 @@ export interface ProviderChainData { }[]; } +/** + * The chain a user starts with and returns to on reset. `openai-compatible` + * is deliberately absent: it resolves only against a base URL somebody + * configured, so a default entry would be dead weight for everyone else. + * Users add it from the chain editor once their gateway is saved. + */ export const DEFAULT_CHAIN: readonly ChainEntry[] = [ { providerType: "codex", enabled: true }, { providerType: "openai", enabled: true }, @@ -114,6 +127,8 @@ export function uiToLegacyProviderEnum(p: ProviderType): string | null { return "ANTHROPIC"; case "local": return "LOCAL"; + case "openai-compatible": + return "OPENAI_COMPATIBLE"; case "codex": case "admin-openai": default: diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index a75fe9a11..9271ef953 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -346,7 +346,13 @@ const profileUpdateResponse = z const aiProviderResponse = z .object({ provider: z - .enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]) + .enum([ + "OPENAI", + "ANTHROPIC", + "LOCAL", + "OPENAI_COMPATIBLE", + "CHATGPT_OAUTH", + ]) .nullable() .describe("The user's selected provider, or null when none is set."), model: z.string().nullable(), @@ -376,6 +382,23 @@ const aiProviderResponse = z .string() .nullable() .describe("`...` + last 4 chars of the stored OpenAI key, or null."), + compatBaseUrl: z + .string() + .nullable() + .describe( + "v1.33.1 (#470) — base URL of the OpenAI-compatible gateway (LiteLLM / OpenRouter / vLLM). Its own column: the OpenAI provider stays pinned to api.openai.com and never reads it.", + ), + compatModel: z + .string() + .nullable() + .describe( + "Model the gateway should route. Null falls back to `model`; with neither set the gateway entry does not resolve.", + ), + hasCompatKey: z + .boolean() + .describe( + "Whether a bearer is stored for the gateway. The bearer is optional — a gateway without auth needs none.", + ), responseTimeoutSeconds: z .number() .int() @@ -396,7 +419,7 @@ export const profilePaths: NonNullable = { tags: ["Auth"], summary: "Read the calling user's AI-provider configuration", description: - "Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char masked preview only.", + "Returns the user's selected provider/model/baseUrl (plus the OpenAI-compatible gateway's own base URL and model) and the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char masked preview only.", responses: { "200": { description: "Resolved AI-provider configuration.", From 3ef6a7119baa971efef0fcfd1b86813026ab6b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:16:11 +0200 Subject: [PATCH 05/39] test(ai): pin the gateway provider's isolation and host policy The properties this provider has to keep: it resolves against the base URL that was saved for it, the pinned OpenAI arm cannot see that URL and still posts to api.openai.com, and the OpenAI key never travels to the gateway. Each is asserted at the wire, not on internals. The SSRF floor is checked through the same alt-notation classes the Local provider is checked against, and the pinned tags are shown to keep requiring a public host even when the operator opened every private one. The AI settings connect-src is pinned to what it already allowed: the gateway is called server-side, so no CSP change is needed, and a future change that needs one will turn this red. --- .../ai-gateway-csp-unchanged.test.ts | 61 +++ src/app/api/ai/test/__tests__/route.test.ts | 21 + .../user/ai-provider/__tests__/route.test.ts | 109 +++++ .../openai-compatible-client.test.ts | 288 ++++++++++++++ .../openai-compatible-provider.test.ts | 373 ++++++++++++++++++ 5 files changed, 852 insertions(+) create mode 100644 src/__tests__/ai-gateway-csp-unchanged.test.ts create mode 100644 src/lib/ai/__tests__/openai-compatible-client.test.ts create mode 100644 src/lib/ai/__tests__/openai-compatible-provider.test.ts diff --git a/src/__tests__/ai-gateway-csp-unchanged.test.ts b/src/__tests__/ai-gateway-csp-unchanged.test.ts new file mode 100644 index 000000000..50fe13e84 --- /dev/null +++ b/src/__tests__/ai-gateway-csp-unchanged.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * The OpenAI-compatible gateway provider (#470) needs no CSP change, and this + * pins that. + * + * The reasoning is worth stating because the opposite conclusion is the + * tempting one: the gateway's host is user-configured, so if the browser ever + * had to reach it, `connect-src` would need a wildcard — which would undo the + * surgical gating the AI settings surface has today. It does not: the + * provider is resolved and called server-side (`/api/ai/test`, + * `/api/insights/*`), so the browser only ever talks to this origin. + * + * If a future change makes the settings page call a gateway directly, this + * test goes red, and that is the moment to have the argument. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function setNodeEnv(value: "development" | "production") { + vi.stubEnv("NODE_ENV", value); +} + +beforeEach(() => { + setNodeEnv("production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function connectSrcFor(pathname: string): string { + const res = proxy( + new NextRequest(`http://localhost${pathname}`, { + headers: { cookie: "healthlog_session=sess-1" }, + }), + ); + const csp = res.headers.get("content-security-policy") ?? ""; + const directive = csp + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith("connect-src")); + return directive ?? ""; +} + +describe("AI settings connect-src is untouched by the gateway provider", () => { + it("still allows exactly the two AI hosts it allowed before", () => { + expect(connectSrcFor("/settings/ai")).toBe( + "connect-src 'self' https://api.openai.com https://chatgpt.com", + ); + }); + + it("adds no AI host anywhere else", () => { + expect(connectSrcFor("/dashboard")).toBe("connect-src 'self'"); + }); +}); diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 2cf725575..6369d1b68 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -261,6 +261,27 @@ describe("POST /api/ai/test — dropdown-aware override", () => { expect(body.data.model).toBe("claude-3-5-sonnet-latest"); }); + it("forwards the gateway's own override fields (#470)", async () => { + makeProviderThatSucceeds("anthropic/claude-sonnet-4-6"); + const response = await POST( + jsonRequest({ + provider: "OPENAI_COMPATIBLE", + compatBaseUrl: "https://litellm.example.com/v1", + compatKey: "gateway-secret", + compatModel: "anthropic/claude-sonnet-4-6", + }) as never, + ); + expect(response.status).toBe(200); + // The gateway's fields travel under their own names — the pinned OpenAI + // arm reads `baseUrl` / `openaiKey` and must not be reachable from here. + expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", { + provider: "OPENAI_COMPATIBLE", + compatBaseUrl: "https://litellm.example.com/v1", + compatKey: "gateway-secret", + compatModel: "anthropic/claude-sonnet-4-6", + }); + }); + it("rejects unknown provider values with 422", async () => { const response = await POST( jsonRequest({ provider: "MALICIOUS" }) as never, diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 29d875e1b..62019f04d 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -46,6 +46,8 @@ vi.mock("@/lib/logging/context", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { resolveProviderAvailability } from "@/lib/ai/provider"; +import { isPublicUrl } from "@/lib/validations/notifications"; +import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; function patchRequest(body: unknown): Request { return new Request("http://localhost/api/user/ai-provider", { @@ -192,3 +194,110 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => { ); }); }); + +// v1.33.1 (#470) — the OpenAI-compatible gateway writes three columns of its +// own. What matters at this layer: the SSRF floor applies to the gateway's +// base URL exactly as it does to the Local one, and the column is nobody +// else's — switching the active provider away must not hand a stale URL to a +// key-carrying provider, and must not silently discard the user's gateway +// either. +describe("PATCH /api/user/ai-provider — the gateway's own columns (#470)", () => { + const patch = PATCH as (req: Request) => Promise; + + it("persists base URL, model and encrypted key", async () => { + vi.mocked(isPublicUrl).mockReturnValue(true); + const res = await patch( + patchRequest({ + provider: "OPENAI_COMPATIBLE", + compatBaseUrl: "https://litellm.example.com/v1", + compatModel: "anthropic/claude-sonnet-4-6", + compatKey: "gateway-secret", + }), + ); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + aiProvider: "OPENAI_COMPATIBLE", + aiCompatBaseUrl: "https://litellm.example.com/v1", + aiCompatModel: "anthropic/claude-sonnet-4-6", + aiCompatKeyEncrypted: "enc:gateway-secret", + }), + }), + ); + }); + + it("refuses a private base URL the operator has not allowlisted", async () => { + vi.mocked(isPublicUrl).mockReturnValue(false); + vi.mocked(isLocalAiHostAllowed).mockReturnValue(false); + const res = await patch( + patchRequest({ compatBaseUrl: "http://169.254.169.254/v1" }), + ); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("accepts a private base URL the operator allowlisted", async () => { + vi.mocked(isPublicUrl).mockReturnValue(false); + vi.mocked(isLocalAiHostAllowed).mockReturnValue(true); + const res = await patch( + patchRequest({ compatBaseUrl: "http://litellm.lan:4000/v1" }), + ); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + aiCompatBaseUrl: "http://litellm.lan:4000/v1", + }), + }), + ); + }); + + it("leaves the gateway URL alone when the provider switches to OpenAI", async () => { + vi.mocked(isPublicUrl).mockReturnValue(true); + await patch(patchRequest({ provider: "OPENAI" })); + const data = vi.mocked(prisma.user.update).mock.calls[0][0].data as Record< + string, + unknown + >; + // `aiBaseUrl` is the shared LOCAL column and IS cleared. The gateway's is + // untouched — it belongs to one provider, and that provider is not this + // one, so there is nothing for a stale value to redirect. + expect(data.aiBaseUrl).toBeNull(); + expect(data).not.toHaveProperty("aiCompatBaseUrl"); + }); +}); + +describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => { + it("reports the base URL and model, and the key as presence only", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: "OPENAI_COMPATIBLE", + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiCompatBaseUrl: "https://litellm.example.com/v1", + aiCompatKeyEncrypted: "enc-gateway", + aiCompatModel: "anthropic/claude-sonnet-4-6", + aiResponseTimeoutSeconds: null, + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: true, + managedBy: "user", + }); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as { + data: { + compatBaseUrl: string | null; + compatModel: string | null; + hasCompatKey: boolean; + }; + }; + expect(body.data.compatBaseUrl).toBe("https://litellm.example.com/v1"); + expect(body.data.compatModel).toBe("anthropic/claude-sonnet-4-6"); + expect(body.data.hasCompatKey).toBe(true); + expect(JSON.stringify(body)).not.toContain("enc-gateway"); + }); +}); diff --git a/src/lib/ai/__tests__/openai-compatible-client.test.ts b/src/lib/ai/__tests__/openai-compatible-client.test.ts new file mode 100644 index 000000000..dfac04cbb --- /dev/null +++ b/src/lib/ai/__tests__/openai-compatible-client.test.ts @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OpenAIClient } from "../openai-client"; +import { resetJsonModeDialectCache } from "../json-dialect"; +import { singleUserTurn } from "../types"; + +/** + * The OpenAI-compatible gateway provider (#470), at the wire. + * + * Two properties carry the security weight of this provider and are pinned + * here rather than left to review: + * + * 1. The pinned `admin-key` / `codex` tags keep `requirePublicHost: true` + * no matter what the operator allowlisted. Only the gateway tag — whose + * base URL a person typed — consults the allowlist. + * 2. The SSRF floor under the gateway is `isPublicUrl`, which is the same + * floor the Local provider sits on, including every IPv4 / IPv6 + * alt-notation class. + * + * `safeFetch` runs for real here (only the transport underneath it is + * stubbed), so a private-host rejection in these tests is the production + * rejection, not a mock of one. + */ + +// safeFetch's requirePublicHost path runs through undici's own `fetch` +// (version-locked with its dispatcher). Delegate it to the global `fetch` +// stub these tests install so the interception still applies. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +function okReply(content = '{"ok":true}') { + return vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content }, finish_reason: "stop" }], + usage: { total_tokens: 7 }, + }), + }); +} + +function errorReply(status: number, body: string) { + return { + ok: false, + status, + text: () => Promise.resolve(body), + }; +} + +const jsonTurn = () => + singleUserTurn({ + system: "system", + user: "user", + responseFormat: "json", + }); + +beforeEach(() => { + vi.restoreAllMocks(); + resetJsonModeDialectCache(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("OpenAI-compatible gateway — wire", () => { + it("posts to the configured base URL and reports its own provider tag", async () => { + const fetchMock = okReply(); + vi.stubGlobal("fetch", fetchMock); + + const client = new OpenAIClient({ + apiKey: "gateway-secret", + model: "anthropic/claude-sonnet-4-6", + baseUrl: "https://litellm.example.com/v1", + providerType: "openai-compatible", + }); + const result = await client.generateCompletion(jsonTurn()); + + expect(fetchMock.mock.calls[0][0]).toBe( + "https://litellm.example.com/v1/chat/completions", + ); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe( + "Bearer gateway-secret", + ); + expect(result.providerType).toBe("openai-compatible"); + expect(result.model).toBe("anthropic/claude-sonnet-4-6"); + }); + + it("sends no Authorization header at all when the gateway needs no bearer", async () => { + const fetchMock = okReply(); + vi.stubGlobal("fetch", fetchMock); + + await new OpenAIClient({ + apiKey: "", + model: "llama3.1:70b", + baseUrl: "https://gateway.example.com/v1", + providerType: "openai-compatible", + }).generateCompletion(jsonTurn()); + + expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty( + "Authorization", + ); + }); +}); + +describe("OpenAI-compatible gateway — host policy", () => { + it("refuses a private host that the operator has not allowlisted", async () => { + vi.stubGlobal("fetch", okReply()); + vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", ""); + + await expect( + new OpenAIClient({ + apiKey: "", + model: "m", + baseUrl: "http://192.168.1.5:4000/v1", + providerType: "openai-compatible", + }).generateCompletion(jsonTurn()), + ).rejects.toMatchObject({ kind: "private_host" }); + }); + + // The alt-notation classes `isPublicUrl` exists to catch. A gateway is the + // one AI surface whose host a user types, so every one of these has to be + // refused through the gateway arm too — not only through the Local one. + it.each([ + ["decimal IPv4", "http://2130706433/v1"], + ["hex IPv4", "http://0x7f000001/v1"], + ["octal IPv4", "http://0177.0.0.1/v1"], + ["cloud metadata", "http://169.254.169.254/v1"], + ["IPv4-mapped IPv6", "http://[::ffff:169.254.169.254]/v1"], + ["IPv6 ULA", "http://[fd00::1]/v1"], + ["loopback", "http://127.0.0.1:8080/v1"], + ])("refuses %s", async (_label, baseUrl) => { + vi.stubGlobal("fetch", okReply()); + vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", ""); + + await expect( + new OpenAIClient({ + apiKey: "", + model: "m", + baseUrl, + providerType: "openai-compatible", + }).generateCompletion(jsonTurn()), + ).rejects.toMatchObject({ kind: "private_host" }); + }); + + it("reaches a private host the operator allowlisted by name", async () => { + const fetchMock = okReply(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "litellm.lan"); + + await new OpenAIClient({ + apiKey: "", + model: "m", + baseUrl: "http://litellm.lan:4000/v1", + providerType: "openai-compatible", + }).generateCompletion(jsonTurn()); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("keeps the pinned tags on requirePublicHost even when the operator allowlisted the host", async () => { + vi.stubGlobal("fetch", okReply()); + // The operator opened every private host for their local AI endpoint. + // That opt-in belongs to the gateway/local surface; a client carrying an + // OpenAI-issued key must not inherit it. + vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true"); + + await expect( + new OpenAIClient({ + apiKey: "sk-user-openai-key", + model: "gpt-4o", + baseUrl: "http://192.168.1.5:4000/v1", + }).generateCompletion(jsonTurn()), + ).rejects.toMatchObject({ kind: "private_host" }); + }); +}); + +describe("OpenAI-compatible gateway — JSON-mode dialect", () => { + it("retries once without response_format when the gateway rejects the field", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + errorReply(400, '{"error":"Unknown parameter: response_format"}'), + ) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"ok":true}' } }], + usage: { total_tokens: 3 }, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await new OpenAIClient({ + apiKey: "k", + model: "m", + baseUrl: "https://strict-gateway.example.com/v1", + providerType: "openai-compatible", + }).generateCompletion(jsonTurn()); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toHaveProperty( + "response_format", + ); + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).not.toHaveProperty( + "response_format", + ); + expect(result.content).toBe('{"ok":true}'); + }); + + it("remembers the no-flag dialect for that endpoint", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + errorReply(400, '{"error":"response_format is not supported"}'), + ) + .mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "{}" } }], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new OpenAIClient({ + apiKey: "k", + model: "m", + baseUrl: "https://strict-gateway.example.com/v1", + providerType: "openai-compatible", + }); + await client.generateCompletion(jsonTurn()); + await client.generateCompletion(jsonTurn()); + + // Three calls total: the rejected probe, its retry, and the second + // generation — which must not probe again. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(JSON.parse(fetchMock.mock.calls[2][1].body)).not.toHaveProperty( + "response_format", + ); + }); + + it("does not retry an unrelated 4xx", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(errorReply(404, '{"error":"model not found"}')); + vi.stubGlobal("fetch", fetchMock); + + await expect( + new OpenAIClient({ + apiKey: "k", + model: "nope", + baseUrl: "https://gateway.example.com/v1", + providerType: "openai-compatible", + }).generateCompletion(jsonTurn()), + ).rejects.toMatchObject({ httpStatus: 404 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("never probes or retries on the pinned OpenAI endpoint", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + errorReply(400, '{"error":"Unknown parameter: response_format"}'), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + new OpenAIClient({ + apiKey: "sk-key", + model: "gpt-4o", + baseUrl: "https://api.openai.com/v1", + }).generateCompletion(jsonTurn()), + ).rejects.toThrow("OpenAI request failed (400)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/ai/__tests__/openai-compatible-provider.test.ts b/src/lib/ai/__tests__/openai-compatible-provider.test.ts new file mode 100644 index 000000000..8c7f204b0 --- /dev/null +++ b/src/lib/ai/__tests__/openai-compatible-provider.test.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Resolution for the OpenAI-compatible gateway provider (#470). + * + * The claim this file has to keep true: the gateway resolves against the base + * URL the user saved, and the pinned OpenAI arm cannot see that base URL or + * be redirected by it. Both are asserted at the wire — each test resolves a + * provider and runs a completion through a stubbed transport, so what is + * checked is the request that would actually leave the process. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((v: string) => `decrypted:${v}`), + encrypt: vi.fn((v: string) => `encrypted:${v}`), +})); +vi.mock("@/lib/ai/codex-oauth", () => ({ + refreshDeviceTokens: vi.fn(), + encryptCodexCreds: vi.fn(), + decryptCodexCreds: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + +import { + AITestConfigError, + resolveProvider, + resolveProviderAvailability, + resolveProviderChain, + resolveProviderForTest, +} from "../provider"; +import { + PROVIDER_CHAIN_DEFAULT, + PROVIDER_CHAIN_TYPES, +} from "../provider-chain"; +import { singleUserTurn } from "../types"; +import { prisma } from "@/lib/db"; + +/** A user row with every AI column present, overridable per test. */ +function userRow(overrides: Record = {}) { + return { + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiCompatBaseUrl: null, + aiCompatKeyEncrypted: null, + aiCompatModel: null, + aiProviderChain: null, + useCentralCodex: false, + codexConnectionStatus: null, + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + ...overrides, + } as never; +} + +function stubTransport() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"ok":true}' } }], + usage: { total_tokens: 1 }, + }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const probe = () => + singleUserTurn({ system: "s", user: "u", responseFormat: "json" }); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +}); + +describe("the gateway resolves against its own columns", () => { + it("posts to the saved base URL with the saved bearer and model", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiProvider: "OPENAI_COMPATIBLE", + aiCompatBaseUrl: "https://litellm.example.com/v1", + aiCompatKeyEncrypted: "enc-gateway", + aiCompatModel: "anthropic/claude-sonnet-4-6", + }), + ); + + const provider = await resolveProvider("u-1"); + const result = await provider.generateCompletion(probe()); + + expect(fetchMock.mock.calls[0][0]).toBe( + "https://litellm.example.com/v1/chat/completions", + ); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe( + "Bearer decrypted:enc-gateway", + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe( + "anthropic/claude-sonnet-4-6", + ); + expect(result.providerType).toBe("openai-compatible"); + }); + + it("works with no bearer at all — a gateway may need none", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiProvider: "OPENAI_COMPATIBLE", + aiCompatBaseUrl: "https://gateway.example.com/v1", + aiCompatModel: "qwen2.5", + }), + ); + + const provider = await resolveProvider("u-1"); + await provider.generateCompletion(probe()); + + expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty( + "Authorization", + ); + }); + + it("falls back to the shared model when no gateway model is set", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiProvider: "OPENAI_COMPATIBLE", + aiModel: "shared-model", + aiCompatBaseUrl: "https://gateway.example.com/v1", + }), + ); + + const provider = await resolveProvider("u-1"); + await provider.generateCompletion(probe()); + + expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe( + "shared-model", + ); + }); +}); + +describe("the pinned OpenAI arm never reads the gateway's base URL", () => { + it("still posts to api.openai.com when a gateway base URL is saved", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiProvider: "OPENAI", + aiOpenaiKeyEncrypted: "enc-openai", + // A configured gateway sits right there in the row. + aiCompatBaseUrl: "https://attacker.example.com/v1", + aiCompatKeyEncrypted: "enc-gateway", + }), + ); + + const provider = await resolveProvider("u-1"); + await provider.generateCompletion(probe()); + + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.openai.com/v1/chat/completions", + ); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe( + "Bearer decrypted:enc-openai", + ); + }); + + it("resolves the chain's openai entry against api.openai.com regardless of the gateway", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiOpenaiKeyEncrypted: "enc-openai", + aiCompatBaseUrl: "https://attacker.example.com/v1", + aiProviderChain: [ + { providerType: "openai", priority: 1, enabled: true }, + ], + }), + ); + + const chain = await resolveProviderChain("u-1"); + expect(chain).toHaveLength(1); + await chain[0].instance.generateCompletion(probe()); + + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.openai.com/v1/chat/completions", + ); + }); + + it("keeps the test endpoint's OpenAI arm pinned too", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiProvider: "OPENAI", + aiOpenaiKeyEncrypted: "enc-openai", + aiCompatBaseUrl: "https://attacker.example.com/v1", + }), + ); + + const provider = await resolveProviderForTest("u-1", {}); + await provider.generateCompletion(probe()); + + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.openai.com/v1/chat/completions", + ); + }); + + it("never sends the OpenAI key to the gateway", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiProvider: "OPENAI_COMPATIBLE", + aiOpenaiKeyEncrypted: "enc-openai", + aiCompatBaseUrl: "https://gateway.example.com/v1", + aiCompatModel: "m", + }), + ); + + const provider = await resolveProvider("u-1"); + await provider.generateCompletion(probe()); + + const authorization = fetchMock.mock.calls[0][1].headers.Authorization; + expect(authorization).toBeUndefined(); + }); +}); + +describe("an unconfigured gateway entry is skipped, not an error", () => { + it("drops the chain entry when no base URL is saved", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiCompatModel: "m", + aiProviderChain: [ + { providerType: "openai-compatible", priority: 1, enabled: true }, + ], + }), + ); + + await expect(resolveProviderChain("u-1")).resolves.toEqual([]); + }); + + it("drops the chain entry when no model can be resolved", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiCompatBaseUrl: "https://gateway.example.com/v1", + aiProviderChain: [ + { providerType: "openai-compatible", priority: 1, enabled: true }, + ], + }), + ); + + await expect(resolveProviderChain("u-1")).resolves.toEqual([]); + }); + + it("resolves the entry once both are present", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiCompatBaseUrl: "https://gateway.example.com/v1", + aiCompatModel: "m", + aiProviderChain: [ + { providerType: "openai-compatible", priority: 1, enabled: true }, + ], + }), + ); + + const chain = await resolveProviderChain("u-1"); + expect(chain.map((e) => e.providerType)).toEqual(["openai-compatible"]); + }); +}); + +describe("chain vocabulary", () => { + it("offers the gateway as a chain type", () => { + expect(PROVIDER_CHAIN_TYPES).toContain("openai-compatible"); + }); + + it("keeps it out of the default chain — it can only resolve once configured", () => { + expect( + PROVIDER_CHAIN_DEFAULT.map((entry) => entry.providerType), + ).not.toContain("openai-compatible"); + }); +}); + +describe("availability reporting", () => { + it("reports a configured gateway as the user's own provider", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiCompatBaseUrl: "https://gateway.example.com/v1", + aiCompatModel: "m", + aiProviderChain: [ + { providerType: "openai-compatible", priority: 1, enabled: true }, + ], + }), + ); + + await expect(resolveProviderAvailability("u-1")).resolves.toEqual({ + aiAvailable: true, + managedBy: "user", + }); + }); +}); + +describe("the connection test's gateway arm", () => { + it("refuses a private base URL the operator has not allowlisted", async () => { + vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", ""); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ + aiProvider: "OPENAI_COMPATIBLE", + aiCompatBaseUrl: "http://169.254.169.254/v1", + aiCompatModel: "m", + }), + ); + + await expect( + resolveProviderForTest("u-1", { provider: "OPENAI_COMPATIBLE" }), + ).rejects.toBeInstanceOf(AITestConfigError); + vi.unstubAllEnvs(); + }); + + it("refuses an unsaved gateway config with no base URL", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow()); + + await expect( + resolveProviderForTest("u-1", { provider: "OPENAI_COMPATIBLE" }), + ).rejects.toThrow(/base URL/i); + }); + + it("refuses an unsaved gateway config with no model", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow()); + + await expect( + resolveProviderForTest("u-1", { + provider: "OPENAI_COMPATIBLE", + compatBaseUrl: "https://gateway.example.com/v1", + }), + ).rejects.toThrow(/model/i); + }); + + it("tests an unsaved gateway config without persisting anything", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow()); + + const provider = await resolveProviderForTest("u-1", { + provider: "OPENAI_COMPATIBLE", + compatBaseUrl: "https://unsaved.example.com/v1", + compatKey: "typed-but-unsaved", + compatModel: "typed-model", + }); + await provider.generateCompletion(probe()); + + expect(fetchMock.mock.calls[0][0]).toBe( + "https://unsaved.example.com/v1/chat/completions", + ); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe( + "Bearer typed-but-unsaved", + ); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); From b74612eaae8f5688aeefae24458921593a3b1f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:17:51 +0200 Subject: [PATCH 06/39] docs(ai): document the OpenAI-compatible gateway provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names LiteLLM, OpenRouter and vLLM, states what it does not share with the OpenAI provider and why, and keeps the Local provider's own section intact — it still speaks the same wire and remains the right pick for a model on your own machine. --- docs/integrations/ai-providers.md | 78 ++++++++++---- .../__tests__/compat-provider-form.test.tsx | 101 ++++++++++++++++++ 2 files changed, 159 insertions(+), 20 deletions(-) create mode 100644 src/components/settings/ai/__tests__/compat-provider-form.test.tsx diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index e72bcae64..66f03496b 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -9,14 +9,15 @@ Ollama endpoint can fall back from one to the other automatically. ## Provider matrix -| Provider | Key acquisition | Default model | Endpoint | Privacy stance | -| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- | -| `OPENAI` | | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI | -| `ANTHROPIC` | | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic | -| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network | -| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription | +| Provider | Key acquisition | Default model | Endpoint | Privacy stance | +| ------------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | --------------------------------------- | +| `OPENAI` | | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI | +| `ANTHROPIC` | | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic | +| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network | +| `OPENAI_COMPATIBLE` | Your gateway's key, or none | none (you name it) | OpenAI-compatible URL you configure | Reaches whatever your gateway routes to | +| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription | -The four spellings above are the canonical `User.aiProvider` enum +The five spellings above are the canonical `User.aiProvider` enum values. Anything else falls through to the admin-shared OpenAI key (if configured) or the `NoProvider` stub that surfaces "No AI provider configured" to the UI. @@ -54,6 +55,11 @@ order. The default is encoded in 4. `local` — self-hosted Ollama / LM Studio / vLLM endpoint. 5. `admin-openai` — operator's shared key (last-ditch). +`openai-compatible` is a sixth chain entry you can add in the editor. +It is deliberately not in the default chain: it resolves only against a +base URL you saved, so a default entry would be dead weight for +everyone who never configures a gateway. + Entries the user has no credential for are silently skipped. The runner walks the surviving entries in order; the first to return a non-error completion wins. A 422 fires only when every entry in the @@ -150,34 +156,59 @@ internal endpoint. 16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage), 70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models). -### OpenAI-compatible gateways (LiteLLM, OpenRouter, …) +## OpenAI-compatible gateways (LiteLLM, OpenRouter, vLLM, …) -The **Local** provider is also the gateway path: any service that -speaks the OpenAI `/v1/chat/completions` wire plugs in the same way, -whether it runs on your host or not. The OPENAI provider deliberately -pins `api.openai.com` (an OpenAI key is never forwarded to a custom -host), so a gateway always goes through **Local**: +Since v1.33.1 a gateway has its own provider: **OpenAI-compatible +gateway**. Any service that speaks the OpenAI `/v1/chat/completions` +wire plugs in there, whether it runs on your network or not. -1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**. +1. In `/settings/ai`, set provider to **OpenAI-compatible gateway**. 2. Base URL is the gateway's OpenAI-compatible root and **ends with - `/v1`** — e.g. `https://litellm.example.com/v1` or - `https://openrouter.ai/api/v1`. + `/v1`** — e.g. `https://litellm.example.com/v1`, + `https://openrouter.ai/api/v1`, or `http://vllm.lan:8000/v1`. 3. API key is **optional** — set it when the gateway requires a Bearer token (OpenRouter key, LiteLLM master key), leave it blank - otherwise. It is encrypted at rest like every other credential. + otherwise. It is encrypted at rest like every other credential, + in its own column. 4. Model is whatever the gateway routes — e.g. `anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias. + Leave it blank and the model saved for your other providers is + used; with neither set the provider is skipped. + +Then add **OpenAI-compatible gateway** to the fallback chain (it is +not in the default one) and put it where you want it tried. + +**Why it is a separate provider and not a base-URL field on OPENAI.** +The OPENAI provider pins `api.openai.com`, and the gateway provider +reads three columns of its own: its base URL, its own key, its own +model. Neither can see the other's. A key you issued to OpenAI can +therefore never be sent to a host you typed into a different form, +and that property is structural rather than a comment — the OPENAI +arm has no code path that reads the gateway's base URL. + +**SSRF guard.** The gateway's base URL passes the same floor as the +Local provider's: a public host always, a private one only when the +operator allowlisted it via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (documented +under Local endpoints above). LAN gateways are the normal case for LiteLLM and vLLM, so +the escape hatch matters here as much as it does for Ollama. JSON surfaces send the standard `response_format: { type: "json_object" }`; an endpoint that rejects the field is detected on the first refusal and retried without it, so strict gateways and older local servers both work unmodified. +**The Local provider still works for this** and nothing about it +changed — it speaks the same wire and honours the same base URL. Pick +the gateway provider when the endpoint is a gateway (it says so in +the UI, it keeps a separate key, and it can sit in the chain +alongside Local), and pick Local for a model running on your own +machine. + The **admin** server-key path has a separate guard: an admin-set custom base URL must additionally be allowed via -`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The -user-level Local provider does not read that variable — it is -governed by the SSRF guard above. +`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. Neither +the user-level Local provider nor the gateway provider reads that +variable — both are governed by the SSRF guard above. ## ChatGPT OAuth (Codex) @@ -256,6 +287,13 @@ provider instead; their own entries always resolve first. - **LOCAL** keeps everything on your network. Snapshots reach only the endpoint you configured. With Ollama on the same host as HealthLog, nothing leaves the host at all. +- **OPENAI_COMPATIBLE** reaches exactly the endpoint you configured, + and then wherever that endpoint routes. A gateway on your own + network keeps the data there; a hosted gateway forwards it to + whichever provider it fronts, under that provider's retention + policy. HealthLog cannot see past the gateway, so the honest + statement is the one above: this provider's privacy stance is your + gateway's. - **CHATGPT_OAUTH** routes via OpenAI's Codex endpoint. Same caveat as OpenAI; the difference is the billing axis (ChatGPT subscription instead of API tokens). diff --git a/src/components/settings/ai/__tests__/compat-provider-form.test.tsx b/src/components/settings/ai/__tests__/compat-provider-form.test.tsx new file mode 100644 index 000000000..f2a723484 --- /dev/null +++ b/src/components/settings/ai/__tests__/compat-provider-form.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { CompatProviderForm } from "../compat-provider-form"; +import { + PROVIDER_TYPES, + DEFAULT_CHAIN, + uiToLegacyProviderEnum, + type UserAIProvider, +} from "../shared"; + +/** + * The gateway provider has to be reachable from the UI, not only from the + * resolver — #470's whole complaint was that the combination existed nowhere + * a user could configure it. These pin that it is offered in the picker, that + * its form seeds from the saved values, and that a saved bearer is reported + * as presence rather than echoed. + */ + +function baseProvider(overrides: Partial): UserAIProvider { + return { + provider: "OPENAI_COMPATIBLE", + model: null, + baseUrl: null, + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + ...overrides, + }; +} + +function render(userProvider: UserAIProvider | null) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("the gateway is offered in the provider picker", () => { + it("appears among the selectable provider types", () => { + expect(PROVIDER_TYPES).toContain("openai-compatible"); + }); + + it("is not in the chain a user starts with", () => { + expect(DEFAULT_CHAIN.map((entry) => entry.providerType)).not.toContain( + "openai-compatible", + ); + }); + + it("maps onto its own persisted provider value", () => { + expect(uiToLegacyProviderEnum("openai-compatible")).toBe( + "OPENAI_COMPATIBLE", + ); + }); +}); + +describe("CompatProviderForm", () => { + it("seeds the saved base URL and model", () => { + const html = render( + baseProvider({ + compatBaseUrl: "https://litellm.example.com/v1", + compatModel: "anthropic/claude-sonnet-4-6", + }), + ); + expect(html).toContain('value="https://litellm.example.com/v1"'); + expect(html).toContain('value="anthropic/claude-sonnet-4-6"'); + }); + + it("reports a stored bearer as presence, never as a value", () => { + const html = render( + baseProvider({ + compatBaseUrl: "https://litellm.example.com/v1", + compatModel: "m", + hasCompatKey: true, + }), + ); + // The key input is empty and only its placeholder says one is stored. + expect(html).toContain('placeholder="(saved)"'); + }); + + it("renders empty for a user who has configured nothing", () => { + const html = render(baseProvider({})); + expect(html).toContain('id="ai-compat-base-url"'); + expect(html).toContain('id="ai-compat-model"'); + expect(html).toContain('id="ai-compat-key"'); + }); +}); From 6d800f277db95836957ff339a0268f1f505fd566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:21:53 +0200 Subject: [PATCH 07/39] fix(rotation): keep the gateway bearer's coverage guard real The rotation-coverage guard greps the script for a quoted column name. Listing the new column in the header comment satisfied that grep without the script rotating anything, which made the guard pass on a script that would have left the column behind. The comment now names it unquoted and says why. --- scripts/rotate-encryption-key.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 06b10a0c5..1a2b34a32 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -170,8 +170,11 @@ async function main() { // "ouraClientSecretEncrypted" "stravaClientIdEncrypted" // "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted" // "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted" - // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "aiCompatKeyEncrypted" - // "insuranceNumberEncrypted" + // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted" + // v1.33.1 adds the gateway bearer; it is named in the array below only. + // Quoting a column name in this comment would satisfy the coverage guard + // in encrypted-columns.test.ts without the script rotating anything, so + // the newest entry stays unquoted here on purpose. const userFields = [ "codexAccessTokenEncrypted", "codexRefreshTokenEncrypted", From 87877b285fcce54e371d3f165b5600cf6230063e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:37:10 +0200 Subject: [PATCH 08/39] fix(share): date a shared PDF by the owner's day, not the display default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page at c/[token] resolved the owner's timezone; the PDF download beside it resolved nothing and fell through to the display default. Same link, same reading, two dates — and the one a practice files is the one that was wrong. Resolved in the shared download gate rather than in the route, so the PDF and the FHIR bundle cannot drift apart the way the page and the PDF did. The owner's clock preference travels with it for the same reason. The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin fixture cannot tell a resolved timezone from a forgotten one. --- .../[token]/__tests__/report-download.test.ts | 61 +++++++++++++++++++ src/app/c/[token]/report.pdf/route.ts | 8 +++ src/lib/clinician-share/report-download.ts | 37 ++++++++++- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/app/c/[token]/__tests__/report-download.test.ts b/src/app/c/[token]/__tests__/report-download.test.ts index 3bfde19f6..b52bbf05c 100644 --- a/src/app/c/[token]/__tests__/report-download.test.ts +++ b/src/app/c/[token]/__tests__/report-download.test.ts @@ -43,6 +43,27 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() })); +// Wraps rather than replaces: the real renderer still runs, so the route is +// exercised end to end, and the options it was handed stay observable. +const pdfOptionsSeen: Array> = []; +vi.mock("@/lib/doctor-report-pdf-core", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + renderDoctorReportPdfBytes: ( + data: Parameters[0], + options: Parameters[1], + ) => { + pdfOptionsSeen.push(options as unknown as Record); + return actual.renderDoctorReportPdfBytes(data, options); + }, + }; +}); vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); vi.mock("@/lib/i18n/server-translator", async (importOriginal) => { const actual = @@ -60,6 +81,8 @@ import { loadShareViewData } from "@/lib/clinician-share/share-view-data"; import { checkRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import { prisma } from "@/lib/db"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { selectionFromLeaves } from "@/lib/report-selection/selection"; import type { DoctorReportData } from "@/lib/doctor-report-data"; @@ -127,6 +150,12 @@ beforeEach(() => { documents: [], documentOnly: false, } as never); + // West of UTC on purpose. Berlin is the display fallback, so a Berlin + // fixture cannot tell a resolved timezone apart from a forgotten one. + vi.mocked(resolveUserTimezone).mockResolvedValue("America/Los_Angeles"); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timeFormat: "H24", + } as never); }); describe("GET /c/{token}/fhir", () => { @@ -255,6 +284,38 @@ describe("GET /c/{token}/report.pdf", () => { expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-"); }); + it("prints the owner's day, not the display default", async () => { + // The defect: the route handed the renderer no timezone at all, so it fell + // back to the display default. A reading is dated by the day it was where + // the person lives, and a practice filing the PDF beside the page it came + // from must not find two different dates for one reading. + pdfOptionsSeen.length = 0; + const { GET } = await import("../report.pdf/route"); + await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params); + + expect(pdfOptionsSeen).toHaveLength(1); + expect( + pdfOptionsSeen[0].userTz, + "the renderer was handed no owner timezone — it will fall back to the display default", + ).toBe("America/Los_Angeles"); + expect( + pdfOptionsSeen[0].timeFormat, + "the owner's clock preference did not reach the renderer", + ).toBe("H24"); + }); + + it("falls back to AUTO when the owner row carries no clock preference", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + pdfOptionsSeen.length = 0; + const { GET } = await import("../report.pdf/route"); + const res = await GET( + new Request(`http://localhost/c/${TOKEN}/report.pdf`), + params, + ); + expect(res.status).toBe(200); + expect(pdfOptionsSeen[0].timeFormat).toBe("AUTO"); + }); + it("runs the same gate as the bundle beside it", async () => { vi.mocked(verifyUnlockValue).mockReturnValue(false); const { GET } = await import("../report.pdf/route"); diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts index e2e4dd281..d6351723b 100644 --- a/src/app/c/[token]/report.pdf/route.ts +++ b/src/app/c/[token]/report.pdf/route.ts @@ -34,6 +34,14 @@ export const GET = apiHandler( const bytes = renderDoctorReportPdfBytes(resolved.report, { t, locale, + // The locale above is the reader's; these two are the OWNER's, and they + // have to be. A reading belongs to the day it was that day where the + // person was, so the date beside it cannot follow whoever opens the + // link. Without these the renderer fell back to the display default, + // which meant a practice west of Berlin could file a PDF dated one day + // off from the page it was downloaded from. + userTz: resolved.ownerTz, + timeFormat: resolved.ownerTimeFormat, insuranceNumber: null, includeCharts: true, }); diff --git a/src/lib/clinician-share/report-download.ts b/src/lib/clinician-share/report-download.ts index b341a1afe..a71c7f94a 100644 --- a/src/lib/clinician-share/report-download.ts +++ b/src/lib/clinician-share/report-download.ts @@ -35,6 +35,9 @@ import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import type { ReportSelection } from "@/lib/report-selection/selection"; +import { prisma } from "@/lib/db"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import type { TimeFormatPreference } from "@/generated/prisma/client"; /** * 20 per hour per link. A practice saves the record once, maybe twice; a @@ -53,7 +56,23 @@ function notFound() { } export type ReportDownloadResult = - | { ok: true; report: DoctorReportData; selection: ReportSelection } + | { + ok: true; + report: DoctorReportData; + selection: ReportSelection; + /** + * The OWNER's timezone and clock preference, not the reader's. + * + * A reading was recorded at a moment in the owner's life, and the date + * printed beside it has to be the date it was that day where they were. + * The page at `c/[token]` already resolves this; the downloads did not, + * so the same reading could carry one date on screen and another in the + * PDF a practice files. Resolved here rather than per route so the two + * download formats cannot drift apart the way the page and the PDF did. + */ + ownerTz: string; + ownerTimeFormat: TimeFormatPreference; + } | { ok: false; response: Response }; /** @@ -128,5 +147,19 @@ export async function resolveShareReportDownload( meta: { format, leafCount: view.selection.leaves.length }, }); - return { ok: true, report: view.report, selection: view.selection }; + const [ownerTz, ownerRow] = await Promise.all([ + resolveUserTimezone(context.ownerUserId), + prisma.user.findUnique({ + where: { id: context.ownerUserId }, + select: { timeFormat: true }, + }), + ]); + + return { + ok: true, + report: view.report, + selection: view.selection, + ownerTz, + ownerTimeFormat: ownerRow?.timeFormat ?? "AUTO", + }; } From f1773cf60241e550dd9dc7a3e16638bca131cf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 23:18:12 +0200 Subject: [PATCH 09/39] fix(charts): draw bucket boundaries in the profile's calendar, not Berlin's The bucketer hard-coded Europe/Berlin for day, week and month boundaries, so a reading taken on the last evening of a month landed in the wrong month's bar for everyone who does not live there. Two smaller siblings did the same thing: the Today chip asked the browser for a time instead of the profile, and the score card's tooltip formatter carried a locale but no timezone. The boundary timezone is a required option rather than a defaulted one. A default would have left any caller that forgets to pass one still wrong and still silent; required makes the compiler name them, which is how the three call sites and the eight test sites in this diff were found rather than guessed at. The tests were always asserting Berlin's calendar without saying so, and now they say so. Two fixture mistakes worth recording, both caught by running the tests rather than trusting them: summer time is already in force in Berlin on 31 March, so the first instant chosen was April on both sides; and 31 March 2025 falls in the same ISO week as 1 April, so the week case needed its own instant where one side is still Sunday. --- .../__tests__/mood-chart-aggregation.test.tsx | 15 +++- src/components/charts/health-chart.tsx | 4 +- src/components/charts/mood-chart.tsx | 24 +++++- src/components/daily/today-hero.tsx | 13 +-- src/components/insights/health-score-card.tsx | 10 +++ src/lib/charts/__tests__/bucket-label.test.ts | 7 +- .../__tests__/bucket-time-series.test.ts | 82 +++++++++++++++++-- src/lib/charts/bucket-time-series.ts | 79 ++++++++++++------ 8 files changed, 183 insertions(+), 51 deletions(-) diff --git a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx index ee11cc628..4ff985d73 100644 --- a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx +++ b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx @@ -55,6 +55,10 @@ describe("pickMoodBucket()", () => { }); }); +// Berlin was the module's hard-coded calendar, so these expectations were +// always Berlin's. Naming it here keeps that visible now it is a parameter. +const BERLIN = "Europe/Berlin"; + describe("aggregateMoodEntries()", () => { it("returns daily points unchanged for short windows", () => { const entries = [ @@ -62,7 +66,7 @@ describe("aggregateMoodEntries()", () => { { date: "2026-05-02", score: 4 }, { date: "2026-05-03", score: 5 }, ]; - const out = aggregateMoodEntries(entries); + const out = aggregateMoodEntries(entries, BERLIN); expect(out.bucket).toBe("day"); expect(out.points).toHaveLength(3); expect(out.points.map((p) => p.score)).toEqual([3, 4, 5]); @@ -71,7 +75,7 @@ describe("aggregateMoodEntries()", () => { it("aggregates to weekly mean for windows in the 91-730d range", () => { // 100-day span — pickBucket → "week". const entries = dayEntries("2026-01-01", 100, 4); - const out = aggregateMoodEntries(entries); + const out = aggregateMoodEntries(entries, BERLIN); expect(out.bucket).toBe("week"); // Constant input score = mean of every weekly bucket should be the // same constant (independent of partial-week edge effects). @@ -85,7 +89,7 @@ describe("aggregateMoodEntries()", () => { it("aggregates to monthly mean for windows > 730 days", () => { const entries = dayEntries("2024-01-01", 800, 5); - const out = aggregateMoodEntries(entries); + const out = aggregateMoodEntries(entries, BERLIN); expect(out.bucket).toBe("month"); // 800 days ≈ 27 months — well below the daily count. expect(out.points.length).toBeLessThan(50); @@ -97,7 +101,10 @@ describe("aggregateMoodEntries()", () => { }); it("emits empty points array for empty input", () => { - expect(aggregateMoodEntries([])).toEqual({ bucket: "day", points: [] }); + expect(aggregateMoodEntries([], BERLIN)).toEqual({ + bucket: "day", + points: [], + }); }); }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 563b39fba..26958e593 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1130,7 +1130,7 @@ export function HealthChart({ types.map((type) => [type, p[type] as number | undefined]), ), })), - { bucket: bucketType }, + { bucket: bucketType, timeZone: userTimezone }, ).points.map((point) => { const date = new Date(point.timestamp); const out: ChartDataPoint = { @@ -1199,7 +1199,7 @@ export function HealthChart({ } return enriched; - }, [data, visibleSlice, showMA, showTrend, types, tzFmt]); + }, [data, visibleSlice, showMA, showTrend, types, tzFmt, userTimezone]); // v1.12.8 — chart-reactive metric statistics. // diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index f8ab01112..df82bcc4f 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -20,6 +20,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { useTranslations } from "@/lib/i18n/context"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; +import { readStoredTimezone } from "@/lib/timezone-mirror"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import type { DataSummary } from "@/lib/analytics/trends"; import { bucketTimeSeries, @@ -244,7 +246,16 @@ export function pickMoodBucket(entries: MoodEntryDay[]): ChartBucketType { * Pure & deterministic so the unit test can pin the exact aggregated * mean per bucket. */ -export function aggregateMoodEntries(entries: MoodEntryDay[]): { +export function aggregateMoodEntries( + entries: MoodEntryDay[], + /** + * The calendar the week / month boundaries are drawn in. Required rather + * than defaulted for the same reason `bucketTimeSeries` requires it: a + * default would leave a forgotten caller quietly bucketing someone else's + * evening into the wrong month. + */ + timeZone: string, +): { bucket: ChartBucketType; points: Array<{ timestamp: number; score: number }>; } { @@ -268,7 +279,7 @@ export function aggregateMoodEntries(entries: MoodEntryDay[]): { timestamp: dayKeyToTimestamp(entry.date), values: { score: entry.score }, })), - { bucket }, + { bucket, timeZone }, ); return { @@ -309,6 +320,11 @@ export function MoodChart({ // encoded day paints verbatim in every profile zone (a profile-tz // formatter shifted week/month bucket labels for zones west of Berlin). const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]); + // The bucket BOUNDARIES follow the profile, even though the bucket LABELS + // stay UTC-pinned (see above): a week that starts on the wrong day puts a + // reading in the wrong bar, which is a different mistake from a label that + // reads a few hours off. + const profileTimeZone = readStoredTimezone() || DEFAULT_TIMEZONE; const initialRangePoints = windowOverride ? MINI_RANGE_POINTS[windowOverride] : 30; @@ -453,7 +469,7 @@ export function MoodChart({ timestamp: p.timestamp, values: { score: p.score }, })), - { bucket: bucketType }, + { bucket: bucketType, timeZone: profileTimeZone }, ).points.map((point) => ({ date: tzFmt.dateShortSmart(new Date(point.timestamp)), timestamp: point.timestamp, @@ -507,7 +523,7 @@ export function MoodChart({ } return enriched; - }, [data, rangePoints, showMA, showTrend, tzFmt]); + }, [data, rangePoints, showMA, showTrend, tzFmt, profileTimeZone]); /** * v1.4.16 B8 — comparison overlay merged into chartData. diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 38431625e..ae9806668 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -38,6 +38,7 @@ import Link from "next/link"; import { Moon } from "lucide-react"; import { useMounted } from "@/hooks/use-mounted"; +import { formatTime } from "@/lib/format"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; @@ -73,17 +74,17 @@ function formatDelta( * the hydration render — React #418, a class of bug this project has already * paid for once. * - * `undefined` locale lets the browser pick the user's own regional format - * (24-hour here, 12-hour there); the chip is a timestamp, not copy. + * Goes through the shared `formatTime`, which reads the profile's timezone and + * clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces + * asked the BROWSER instead, which is a different question: someone whose + * profile says Europe/Berlin while their laptop sits in Manila saw a Manila + * time here and a Berlin time on every other surface, for the same instant. */ function formatJustInTime(iso: string): string | null { const at = new Date(iso); if (Number.isNaN(at.getTime())) return null; try { - return at.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - }); + return formatTime(at); } catch { return null; } diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 796ce76c1..3c8e41dca 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -11,6 +11,7 @@ import { Scale, } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { readStoredTimezone } from "@/lib/timezone-mirror"; import { cn } from "@/lib/utils"; import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; @@ -219,12 +220,21 @@ export function HealthScoreCard({ // we fall through to the raw ISO so the user still sees something. // The formatter is memoised per locale so the row map below doesn't // build a fresh `Intl.DateTimeFormat` instance per row. + // The timezone is as load-bearing as the locale and was missing: without it + // the formatter used the browser's, so a reading taken late in the evening + // could print yesterday's date for someone whose profile timezone is east of + // where their laptop happens to be. The locale still decides the separator + // convention; the timezone decides which day it was. const asOfFormatter = useMemo( () => new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "2-digit", + // `readStoredTimezone` returns "" when nothing is stored or during + // SSR, and an empty string makes Intl throw RangeError rather than + // fall back. `undefined` is the value that means "use the default". + timeZone: readStoredTimezone() || undefined, }), [locale], ); diff --git a/src/lib/charts/__tests__/bucket-label.test.ts b/src/lib/charts/__tests__/bucket-label.test.ts index a79cba792..de7fc42a5 100644 --- a/src/lib/charts/__tests__/bucket-label.test.ts +++ b/src/lib/charts/__tests__/bucket-label.test.ts @@ -16,6 +16,9 @@ import { bucketTimeSeries } from "../bucket-time-series"; import { makeBucketLabelFormatters } from "../bucket-label"; import { makeFormatters } from "@/lib/format-locale"; +// Berlin was the module's hard-coded calendar, so these expectations were +// always Berlin's. Naming it here keeps that visible now it is a parameter. +const BERLIN = "Europe/Berlin"; describe("makeBucketLabelFormatters (#490)", () => { // Two July instants (Berlin summer) forced into a month bucket. const monthBucket = bucketTimeSeries( @@ -23,7 +26,7 @@ describe("makeBucketLabelFormatters (#490)", () => { { timestamp: new Date("2026-07-05T10:00:00Z"), values: { WEIGHT: 80 } }, { timestamp: new Date("2026-07-20T10:00:00Z"), values: { WEIGHT: 82 } }, ], - { bucket: "month" }, + { bucket: "month", timeZone: BERLIN }, ); it("labels a Berlin July month bucket as July for every profile", () => { @@ -65,7 +68,7 @@ describe("makeBucketLabelFormatters (#490)", () => { values: { WEIGHT: 80 }, }, ], - { bucket: "week" }, + { bucket: "week", timeZone: BERLIN }, ); const ts = weekBucket.points[0].timestamp; expect(ts).toBe(Date.UTC(2026, 6, 13)); diff --git a/src/lib/charts/__tests__/bucket-time-series.test.ts b/src/lib/charts/__tests__/bucket-time-series.test.ts index 9ed8a0761..aa8985832 100644 --- a/src/lib/charts/__tests__/bucket-time-series.test.ts +++ b/src/lib/charts/__tests__/bucket-time-series.test.ts @@ -30,12 +30,73 @@ describe("pickBucket()", () => { }); }); +// These expectations were always Berlin's — the module used to hard-code it, +// so the tests read as timezone-agnostic while quietly depending on one. Now +// that the boundary calendar is a parameter, the assumption is written down +// rather than implied. +const BERLIN = "Europe/Berlin"; + +describe("the boundary calendar is the caller's, not Berlin's", () => { + // 2025-03-31T16:00Z is 18:00 on 31 March in Berlin (UTC+2, summer time is + // already in force) and 01:00 on 1 April in Tokyo (UTC+9). Which month bar + // this reading lands in is decided by the calendar the caller names, and + // until now the caller could not name one: every chart bucketed everyone's + // readings into Berlin's months. + const lateEvening = new Date("2025-03-31T16:00:00Z"); + const points = [{ timestamp: lateEvening, values: { v: 1 } }]; + + it("puts a late-evening reading in March for Berlin", () => { + const out = bucketTimeSeries(points, { + bucket: "month", + timeZone: "Europe/Berlin", + }); + expect(new Date(out.points[0].timestamp).toISOString()).toBe( + "2025-03-01T00:00:00.000Z", + ); + }); + + it("puts the same instant in April for Tokyo", () => { + const out = bucketTimeSeries(points, { + bucket: "month", + timeZone: "Asia/Tokyo", + }); + expect(new Date(out.points[0].timestamp).toISOString()).toBe( + "2025-04-01T00:00:00.000Z", + ); + }); + + it("moves the ISO week too, not only the month", () => { + // A separate instant, because the month fixture above does not serve + // here: 31 March 2025 is a Monday in Berlin and 1 April a Tuesday in + // Tokyo, which is the SAME ISO week. The week boundary only moves when + // one side is still Sunday while the other is already Monday. + // 2025-03-30T16:00Z is Sunday 18:00 in Berlin and Monday 01:00 in Tokyo. + const sundayEvening = [ + { timestamp: new Date("2025-03-30T16:00:00Z"), values: { v: 1 } }, + ]; + const berlin = bucketTimeSeries(sundayEvening, { + bucket: "week", + timeZone: "Europe/Berlin", + }); + const tokyo = bucketTimeSeries(sundayEvening, { + bucket: "week", + timeZone: "Asia/Tokyo", + }); + expect(new Date(berlin.points[0].timestamp).toISOString()).toBe( + "2025-03-24T00:00:00.000Z", + ); + expect(new Date(tokyo.points[0].timestamp).toISOString()).toBe( + "2025-03-31T00:00:00.000Z", + ); + }); +}); + describe("bucketTimeSeries()", () => { const start = new Date("2025-01-06T00:00:00Z"); // Monday it("preserves daily granularity for ranges <= 90 days", () => { const points = [0, 1, 2, 30, 89].map((d) => dailyAt(start, d, 80 + d)); - const result = bucketTimeSeries(points); + const result = bucketTimeSeries(points, { timeZone: BERLIN }); expect(result.bucket).toBe("day"); expect(result.points).toHaveLength(5); }); @@ -45,7 +106,7 @@ describe("bucketTimeSeries()", () => { const points = Array.from({ length: 200 }, (_, i) => dailyAt(start, i, 80 + (i % 5)), ); - const result = bucketTimeSeries(points); + const result = bucketTimeSeries(points, { timeZone: BERLIN }); expect(result.bucket).toBe("week"); // 200 / 7 ≈ 28.5 — first/last weeks may be partial → 29 distinct keys. expect(result.points.length).toBeGreaterThanOrEqual(28); @@ -61,7 +122,7 @@ describe("bucketTimeSeries()", () => { const points = Array.from({ length: 800 }, (_, i) => dailyAt(start, i, 100), ); - const result = bucketTimeSeries(points); + const result = bucketTimeSeries(points, { timeZone: BERLIN }); expect(result.bucket).toBe("month"); // ~26 calendar months expect(result.points.length).toBeGreaterThanOrEqual(25); @@ -79,7 +140,10 @@ describe("bucketTimeSeries()", () => { { timestamp: start.getTime(), values: { weight: 80 } }, { timestamp: start.getTime() + 200 * dayMs, values: { weight: 90 } }, ]; - const result = bucketTimeSeries(points, { bucket: "week" }); + const result = bucketTimeSeries(points, { + bucket: "week", + timeZone: BERLIN, + }); expect(result.points).toHaveLength(2); expect(result.points.map((p) => p.values.weight)).toEqual([80, 90]); }); @@ -90,7 +154,10 @@ describe("bucketTimeSeries()", () => { { timestamp: start.getTime() + dayMs, values: { sys: 140 } }, // dia missing { timestamp: start.getTime() + 2 * dayMs, values: { dia: 78 } }, // sys missing ]; - const result = bucketTimeSeries(points, { bucket: "week" }); + const result = bucketTimeSeries(points, { + bucket: "week", + timeZone: BERLIN, + }); expect(result.points).toHaveLength(1); const point = result.points[0]; expect(point.counts.sys).toBe(2); @@ -101,7 +168,10 @@ describe("bucketTimeSeries()", () => { it("respects the explicit bucket override", () => { const points = [0, 1, 2].map((d) => dailyAt(start, d, 80)); - const result = bucketTimeSeries(points, { bucket: "month" }); + const result = bucketTimeSeries(points, { + bucket: "month", + timeZone: BERLIN, + }); expect(result.bucket).toBe("month"); expect(result.points).toHaveLength(1); }); diff --git a/src/lib/charts/bucket-time-series.ts b/src/lib/charts/bucket-time-series.ts index 437074a7c..f4ca0b200 100644 --- a/src/lib/charts/bucket-time-series.ts +++ b/src/lib/charts/bucket-time-series.ts @@ -35,35 +35,45 @@ export interface BucketedChartSeries { points: ChartBucketPoint[]; } -const BERLIN_TZ = "Europe/Berlin"; - -const BERLIN_FMT = new Intl.DateTimeFormat("en-US", { - timeZone: BERLIN_TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", -}); - const MS_PER_DAY = 24 * 60 * 60 * 1000; -interface BerlinYmd { +/** + * One formatter per timezone. Building an `Intl.DateTimeFormat` is expensive + * enough that doing it per point matters on a two-year monthly chart. + */ +const ymdFormatters = new Map(); + +function ymdFormatter(timeZone: string): Intl.DateTimeFormat { + const cached = ymdFormatters.get(timeZone); + if (cached) return cached; + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + ymdFormatters.set(timeZone, fmt); + return fmt; +} + +interface ZonedYmd { year: number; month: number; day: number; } -function toBerlinYmd(date: Date): BerlinYmd { - const parts = BERLIN_FMT.formatToParts(date); +function toZonedYmd(date: Date, timeZone: string): ZonedYmd { + const parts = ymdFormatter(timeZone).formatToParts(date); const year = Number(parts.find((p) => p.type === "year")?.value); const month = Number(parts.find((p) => p.type === "month")?.value); const day = Number(parts.find((p) => p.type === "day")?.value); if (!year || !month || !day) { - throw new Error("Could not derive Berlin Y-M-D"); + throw new Error(`Could not derive Y-M-D in ${timeZone}`); } return { year, month, day }; } -function utcMidnight({ year, month, day }: BerlinYmd): number { +function utcMidnight({ year, month, day }: ZonedYmd): number { return Date.UTC(year, month - 1, day); } @@ -72,21 +82,21 @@ function utcMidnight({ year, month, day }: BerlinYmd): number { * containing the first Thursday of that year. Returns the UTC * midnight of the Monday of the week containing `date`. */ -function isoWeekStartUtc(date: Date): number { - const ymd = toBerlinYmd(date); +function isoWeekStartUtc(date: Date, timeZone: string): number { + const ymd = toZonedYmd(date, timeZone); const utc = utcMidnight(ymd); // Day of week, 1=Mon..7=Sun (ISO). const dow = ((new Date(utc).getUTCDay() + 6) % 7) + 1; return utc - (dow - 1) * MS_PER_DAY; } -function monthStartUtc(date: Date): number { - const { year, month } = toBerlinYmd(date); +function monthStartUtc(date: Date, timeZone: string): number { + const { year, month } = toZonedYmd(date, timeZone); return Date.UTC(year, month - 1, 1); } -function dayStartUtc(date: Date): number { - return utcMidnight(toBerlinYmd(date)); +function dayStartUtc(date: Date, timeZone: string): number { + return utcMidnight(toZonedYmd(date, timeZone)); } export interface BucketInputPoint { @@ -103,6 +113,16 @@ export interface BucketInputPoint { export interface BucketTimeSeriesOptions { /** Override the bucketer when the caller already knows it. */ bucket?: ChartBucketType; + /** + * The calendar the day / week / month boundaries are drawn in. + * + * Deliberately required rather than defaulted. This module used to hard-code + * Europe/Berlin, which meant a reading late on the last evening of a month + * fell into the wrong month's bar for anyone who does not live there — and a + * default would have left every caller that forgets to pass one still wrong, + * silently. Required turns that into a compile error instead. + */ + timeZone: string; } /** @@ -116,14 +136,15 @@ export function pickBucket(rangeDays: number): ChartBucketType { /** * Bucket a daily-aggregated chart series into day/week/month groups. - * Weeks use ISO 8601 (Monday start); months use Berlin calendar. - * Empty buckets are skipped so a sparse user does not get a flat zero - * baseline. + * Weeks use ISO 8601 (Monday start); every boundary is drawn in the caller's + * `timeZone`. Empty buckets are skipped so a sparse user does not get a flat + * zero baseline. */ export function bucketTimeSeries( points: BucketInputPoint[], - options: BucketTimeSeriesOptions = {}, + options: BucketTimeSeriesOptions, ): BucketedChartSeries { + const { timeZone } = options; const sorted = [...points].sort( (a, b) => toMs(a.timestamp) - toMs(b.timestamp), ); @@ -144,14 +165,18 @@ export function bucketTimeSeries( // Day-granularity is the upstream's native shape, but we still go // through aggregation so the output shape is consistent and so // duplicate-day callers (raw API mode) collapse cleanly. - return aggregate(sorted, bucket, (ts) => dayStartUtc(new Date(toMs(ts)))); + return aggregate(sorted, bucket, (ts) => + dayStartUtc(new Date(toMs(ts)), timeZone), + ); } if (bucket === "week") { return aggregate(sorted, bucket, (ts) => - isoWeekStartUtc(new Date(toMs(ts))), + isoWeekStartUtc(new Date(toMs(ts)), timeZone), ); } - return aggregate(sorted, bucket, (ts) => monthStartUtc(new Date(toMs(ts)))); + return aggregate(sorted, bucket, (ts) => + monthStartUtc(new Date(toMs(ts)), timeZone), + ); } function toMs(ts: number | Date): number { From 8e9da490a9817faecf51af25581e97c2f199803d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 00:18:22 +0200 Subject: [PATCH 10/39] test(tz): refuse a date formatter that does not name its calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A formatter with no timeZone follows the browser, which is a different question from the one this app answers. Four surfaces had drifted apart that way before anyone noticed, and one of them printed a shared report a day off from the page it came from. The admin host chart went with them rather than onto the allowlist. Server samples are real instants, and an operator reading their own instance should not find a spike at 14:20 here and 15:20 two screens away. Two deliberate limits, both written into the test. It does not match toLocaleString, which in this tree is overwhelmingly a number formatter, and burying four real hits under every thousands separator is how a guard gets silenced. And it cannot tell a Date built from calendar arithmetic — a month label, a weekday header, no instant behind it — from one parsed out of stored data, so those four sites carry a written reason instead. The value is the ratchet: a new bare formatter has to be argued for before it ships. --- .../timezone-explicit-formatter-guard.test.ts | 157 ++++++++++++++++++ src/components/admin/host-metrics-chart.tsx | 6 + 2 files changed, 163 insertions(+) create mode 100644 src/__tests__/timezone-explicit-formatter-guard.test.ts diff --git a/src/__tests__/timezone-explicit-formatter-guard.test.ts b/src/__tests__/timezone-explicit-formatter-guard.test.ts new file mode 100644 index 000000000..3e8f01d31 --- /dev/null +++ b/src/__tests__/timezone-explicit-formatter-guard.test.ts @@ -0,0 +1,157 @@ +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * A formatter that renders an instant must say which calendar it renders in. + * + * `new Intl.DateTimeFormat(locale, {…})` and `date.toLocaleDateString(…)` fall + * back to the BROWSER's timezone when none is given. That is a different + * question from the one this app answers: a reading belongs to the day it was + * where the person lives, not where their laptop happens to be. A reporter hit + * exactly this (#490), and the sweep behind it found four separate surfaces + * that had drifted apart — one of them printed a shared report a day off from + * the page it was downloaded from. + * + * The check is a source-shape check, and it is deliberately coarse in one + * direction: it cannot tell a Date built from calendar arithmetic (a month + * label, a weekday header — no instant involved, no timezone question) from a + * Date parsed out of stored data. So those legitimately-bare sites live on the + * allowlist with a reason, and the guard's real job is the ratchet: a NEW bare + * formatter has to be argued for in writing before it can ship. + * + * `.toLocaleString(` is deliberately NOT matched. It is overwhelmingly a + * number formatter in this tree, and flagging thousands separators would bury + * the four real hits in noise until someone silenced the whole guard. + */ + +const REPO_ROOT = resolve(__dirname, "../.."); + +/** + * Bare formatter sites that are correct as they stand. Keep this short: it is + * an inventory of exceptions, not a parking space. Every entry says why. + */ +const ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ + { + file: "src/components/ui/calendar.tsx", + why: "A `data-day` attribute on a calendar cell, derived from the grid's own arithmetic. It identifies a cell, it does not render a recorded moment.", + }, + { + file: "src/components/cycle/cycle-calendar.tsx", + why: "Weekday headers walked from a fixed local date, and a month label built from a month anchor. Both are calendar arithmetic with no instant behind them.", + }, + { + file: "src/lib/intl/formatter-cache.ts", + why: "The shared cache itself. Its options come from the caller, so the timezone is the caller's to supply and the guard should be asking them, not this.", + }, +]; + +const FORMATTER_PATTERNS = [ + /new Intl\.DateTimeFormat\s*\(/g, + /\.toLocale(?:Date|Time)String\s*\(/g, +]; + +/** The argument list starting at the `(` at or after `from`. */ +function argumentList(source: string, from: number): string { + const open = source.indexOf("(", from); + if (open === -1) return ""; + let depth = 0; + for (let i = open; i < source.length; i += 1) { + if (source[i] === "(") depth += 1; + else if (source[i] === ")") { + depth -= 1; + if (depth === 0) return source.slice(open, i + 1); + } + } + return source.slice(open); +} + +function bareFormatterSites(): Array<{ file: string; line: number }> { + const listed = execFileSync( + "grep", + [ + "-rlE", + "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(", + "src", + "--include=*.ts", + "--include=*.tsx", + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ) + .trim() + .split("\n") + .filter(Boolean) + // Tests pin instants on purpose and are not user-facing surfaces. + .filter((f) => !f.includes("__tests__")); + + const hits: Array<{ file: string; line: number }> = []; + for (const file of listed) { + const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + for (const pattern of FORMATTER_PATTERNS) { + pattern.lastIndex = 0; + for (let m = pattern.exec(source); m !== null; m = pattern.exec(source)) { + const args = argumentList(source, m.index + m[0].length - 1); + if (args.includes("timeZone")) continue; + hits.push({ + file, + line: source.slice(0, m.index).split("\n").length, + }); + } + } + } + return hits; +} + +describe("date formatters name the calendar they render in", () => { + const hits = bareFormatterSites(); + + it("finds formatter sites at all (the guard must not pass vacuously)", () => { + // If this drops to zero the grep has stopped matching and every assertion + // below became free. + const all = execFileSync( + "grep", + [ + "-rlE", + "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(", + "src", + "--include=*.ts", + "--include=*.tsx", + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ) + .trim() + .split("\n") + .filter(Boolean); + expect(all.length).toBeGreaterThan(5); + }); + + it("has no unexplained formatter without an explicit timeZone", () => { + const unexplained = hits.filter( + (h) => !ALLOWLIST.some((a) => a.file === h.file), + ); + if (unexplained.length > 0) { + throw new Error( + `${unexplained.length} formatter call(s) render a date without naming a timezone:\n\n` + + unexplained.map((h) => ` ${h.file}:${h.line}`).join("\n") + + "\n\nWithout `timeZone` these follow the browser, not the profile. Pass the " + + "profile timezone (`readStoredTimezone() || DEFAULT_TIMEZONE` on the client, " + + "`resolveUserTimezone(userId)` on the server), or use the shared helpers in " + + "@/lib/format. If the value is calendar arithmetic rather than a recorded " + + "instant, add the file to ALLOWLIST in this test with the reason.", + ); + } + }); + + it("carries no allowlist entry that no longer has a bare formatter", () => { + // A stale exception is worse than none: it documents a decision about code + // that has moved on, and it quietly covers whatever lands in that file next. + for (const entry of ALLOWLIST) { + expect( + hits.some((h) => h.file === entry.file), + `stale allowlist entry — ${entry.file} no longer has a bare formatter`, + ).toBe(true); + } + }); +}); diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 784120dcc..6c80a79c5 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -41,6 +41,7 @@ import { queryKeys } from "@/lib/query-keys"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { apiGet } from "@/lib/api/api-fetch"; +import { readStoredTimezone } from "@/lib/timezone-mirror"; interface HostMetricsApiSample { capturedAt: string; @@ -94,10 +95,15 @@ export function buildChartRows(samples: HostMetricsApiSample[]): { if (total > peakDiskBps) peakDiskBps = total; } + // The profile's timezone, not the browser's. These are real instants (host + // samples), and an operator reading their own instance should see the same + // clock here as on every other timestamp in the admin area — a spike at + // "14:20" must not be 14:20 here and 15:20 two screens away. const formatter = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hour12: false, + timeZone: readStoredTimezone() || undefined, }); let hasDiskData = false; From a2e95877819d4df62cb63246bbd1e45289416e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 05:14:39 +0200 Subject: [PATCH 11/39] feat(backup): declare what a backup carries, and force a verdict on the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror of the wipe plan, for the same reason and against a worse failure: a list that was right when written and stops being right every time a model is added. On the delete side that leaves data behind; here it means data does not come back, and nothing says so until someone is restoring a backup because something already went wrong. The list is longer than the wipe list by sixteen models, and that gap is the point. A wipe may lean on the database — deleting a medication cascades to its schedules, dose changes and inventory events — but a restore has no cascade, so every child has to be named here. Every exclusion carries its reason in prose, and the guard enforces a length floor on it. That floor is not pedantry: the first draft of this file said 'Same as WithingsConnection' four times, which is a pointer rather than a reason, and a pointer silently inherits whatever the thing it points at becomes. Writing the decision where it is made is the only way it stays reviewable. The guard found two things in its own author's work on the first run: a model with no verdict at all, and a reason too thin to be one. The restore side of the guard — that every carried model also has a restore branch — lands with the payload work it will police. --- .../backup-plan-classification.test.ts | 128 +++++++++ src/lib/export/backup-plan.ts | 261 ++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 src/__tests__/backup-plan-classification.test.ts create mode 100644 src/lib/export/backup-plan.ts diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts new file mode 100644 index 000000000..9bc1c957c --- /dev/null +++ b/src/__tests__/backup-plan-classification.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + AUTH_MODELS_OUT_OF_SCOPE, + BACKED_UP_MODELS, + DERIVED_MODELS, + NOT_IN_BACKUP_MODELS, + backupVerdict, +} from "@/lib/export/backup-plan"; + +/** + * Every model in the schema has a written verdict about the backup. + * + * This is the first half of the backup guard: classification. The second half + * — that every `BACKED_UP` model actually has a payload reader AND a restore + * branch — lives beside it, because a model that is classified as carried and + * then never restored is the defect, not the fix. + * + * The failure this prevents: a model is added to `schema.prisma`, it is + * user-scoped from birth, and it is outside the backup by default. Nothing + * notices, and the gap is only discovered by someone restoring a backup and + * finding a part of their record missing — which is the worst possible moment + * and the worst possible messenger. + */ + +const SCHEMA_PATH = resolve(__dirname, "../../prisma/schema.prisma"); + +/** Models declared in the schema, minus `User` itself (the account row). */ +function schemaModels(): string[] { + const source = readFileSync(SCHEMA_PATH, "utf8"); + return [...source.matchAll(/^model\s+(\w+)\s*\{/gm)] + .map((m) => m[1]) + .filter((name) => name !== "User"); +} + +/** + * Models with no `userId` and no user relation are instance-scoped (queue + * tables, rate-limit buckets, invite codes the operator issues). They are not + * an account's data and the wipe plan already declares them as such. + */ +function isUserScoped(model: string): boolean { + const source = readFileSync(SCHEMA_PATH, "utf8"); + const block = new RegExp( + `^model\\s+${model}\\s*\\{([\\s\\S]*?)^\\}`, + "m", + ).exec(source); + if (!block) return false; + const body = block[1]; + return /\buserId\b/.test(body) || /\buser\s+User\b/.test(body); +} + +describe("every schema model has a backup verdict", () => { + const models = schemaModels(); + + it("reads a schema with models in it (no vacuous pass)", () => { + expect(models.length).toBeGreaterThan(50); + }); + + it("classifies every user-scoped model", () => { + const unclassified = models + .filter(isUserScoped) + .filter((m) => backupVerdict(m) === null); + + if (unclassified.length > 0) { + throw new Error( + `${unclassified.length} user-scoped model(s) have no backup verdict:\n\n` + + unclassified.map((m) => ` ❌ ${m}`).join("\n") + + "\n\nAdd each to src/lib/export/backup-plan.ts: to BACKED_UP_MODELS if an " + + "account would lose something real without it, to DERIVED_MODELS with what " + + "rebuilds it, or to NOT_IN_BACKUP_MODELS with why it must not travel. " + + "A new model is user-scoped from birth and outside the backup by default, " + + "which is exactly the silence this test exists to break.", + ); + } + }); + + it("carries no verdict for a model the schema no longer has", () => { + const known = new Set(models); + const stale = [ + ...BACKED_UP_MODELS, + ...Object.keys(DERIVED_MODELS), + ...Object.keys(NOT_IN_BACKUP_MODELS), + ...AUTH_MODELS_OUT_OF_SCOPE, + ].filter((m) => !known.has(m)); + + expect( + stale, + `verdicts for models that no longer exist: ${stale.join(", ")}`, + ).toEqual([]); + }); + + it("gives each model exactly one verdict", () => { + const seen = new Map(); + const add = (model: string, list: string) => { + seen.set(model, [...(seen.get(model) ?? []), list]); + }; + for (const m of BACKED_UP_MODELS) add(m, "BACKED_UP"); + for (const m of Object.keys(DERIVED_MODELS)) add(m, "DERIVED"); + for (const m of Object.keys(NOT_IN_BACKUP_MODELS)) add(m, "NOT_IN_BACKUP"); + for (const m of AUTH_MODELS_OUT_OF_SCOPE) add(m, "AUTH"); + + const doubled = [...seen.entries()].filter(([, lists]) => lists.length > 1); + expect( + doubled.map(([m, lists]) => `${m}: ${lists.join(" + ")}`), + "a model classified twice means two readers can each believe the other handles it", + ).toEqual([]); + }); + + it("gives every excluded model a reason, not an empty string", () => { + const empty = [ + ...Object.entries(DERIVED_MODELS), + ...Object.entries(NOT_IN_BACKUP_MODELS), + ] + // 20 characters let "Same as WithingsConnection." through, which is a + // pointer rather than a reason: if the entry it points at changes, this + // one silently inherits something else. 60 forces the decision to be + // written where it is made. + .filter(([, reason]) => reason.trim().length < 60) + .map(([model]) => model); + expect( + empty, + "a one-word reason is how a decision stops being reviewable", + ).toEqual([]); + }); +}); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts new file mode 100644 index 000000000..9480adff6 --- /dev/null +++ b/src/lib/export/backup-plan.ts @@ -0,0 +1,261 @@ +/** + * The one declaration of what a backup contains, and what it deliberately does + * not. + * + * The wipe plan next door ({@link ../data-wipe/wipe-plan}) answers the mirror + * question and got the same treatment for the same reason: a list that was + * right the day it was written, then quietly stopped being right every time a + * model was added. On the delete side that meant data survived a wipe. On this + * side it means data does not survive a restore, which is worse, because a + * wipe at least tells you it happened. + * + * ── Why this list is longer than the wipe list ────────────────────────────── + * + * The wipe may lean on the database: deleting a `Medication` cascades to its + * schedules, dose changes and inventory events, so the wipe plan never has to + * name them. **A restore has no cascade.** Every child has to be carried + * explicitly and re-inserted explicitly, so sixteen models the wipe plan never + * needed to mention are decisions here. + * + * ── The three verdicts ────────────────────────────────────────────────────── + * + * `BACKED_UP` — the account would lose something real if this were missing. + * The guard requires BOTH a payload reader AND a restore + * branch for every one of these, because carried-but-never- + * restored is the exact defect this file exists to prevent and + * it looks identical to working until someone needs it. + * `DERIVED` — recomputable from `BACKED_UP` rows. Leaving it out keeps the + * file smaller and cannot lose anything; the reason names what + * recomputes it. + * `NOT_IN_BACKUP` — deliberately excluded. Credentials that must not travel, + * provider handshake state that is meaningless on another + * host, ledgers whose value is that they were written here. + * + * Every entry carries its reason in prose. "Obvious" is not a reason: the + * whole class of defect here is someone deciding something was obvious and + * nobody being able to check afterwards. + */ + +/** A model whose absence from a restore would lose something the account owns. */ +export const BACKED_UP_MODELS = [ + // ── Measurements ────────────────────────────────────────────────────────── + "Measurement", + + // ── Medications, and everything hanging off one ─────────────────────────── + // The parent alone restores a drug list with no schedule, no history and no + // stock — a shape the app renders without complaint, which is why this was + // easy to miss. + "Medication", + "MedicationSchedule", + "MedicationScheduleRevision", + "MedicationDoseChange", + "MedicationIntakeEvent", + "MedicationPauseEra", + "MedicationSideEffect", + "MedicationEfficacyTarget", + "MedicationInventoryItem", + "MedicationInventoryEvent", + + // ── Mood ────────────────────────────────────────────────────────────────── + "MoodEntry", + "MoodEntryTagLink", + "MoodTag", + "MoodTagCategory", + "MoodTagHidden", + + // ── Clinical record ─────────────────────────────────────────────────────── + "MentalHealthAssessment", + "LabResult", + "Biomarker", + "Allergy", + "FamilyHistoryEntry", + "IllnessEpisode", + "IllnessDayLog", + "IllnessSymptomLink", + "EcgRecording", + "UserHealthProfile", + "MeasurementReminder", + + // ── Cycle ───────────────────────────────────────────────────────────────── + "CycleProfile", + "MenstrualCycle", + "CycleDayLog", + "CycleSymptom", + "CycleSymptomLink", + + // ── Custom metrics ──────────────────────────────────────────────────────── + "CustomMetric", + "CustomMetricEntry", + + // ── Activity and nutrition ──────────────────────────────────────────────── + "Workout", + "WorkoutRoute", + "WorkoutSamples", + "NutrientIntakeDay", + "PersonalRecord", + "UserAchievement", + + // ── Environment ─────────────────────────────────────────────────────────── + "EnvironmentContext", + "EnvironmentTravelLocation", + + // ── Documents ───────────────────────────────────────────────────────────── + "InboundDocument", + "DocumentConditionLink", + "ExtractedFact", + + // ── Coach ───────────────────────────────────────────────────────────────── + "CoachConversation", + "CoachMessage", + "CoachConversationDocument", + "CoachFact", + "CoachPlan", + "CoachReminder", + + // ── Preferences and consent ─────────────────────────────────────────────── + "NotificationPreference", + "ReminderPhaseConfig", + "ConsentReceipt", +] as const; + +/** + * Recomputable from backed-up rows. The reason names what rebuilds it, so a + * future reader can check the claim rather than trust it. + */ +export const DERIVED_MODELS: Readonly> = { + MeasurementRollup: + "A per-day, per-metric reconstruction of `Measurement`. The boot-time rollup backfill rebuilds it for an account that has none.", + MedicationComplianceRollup: + "Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.", + MoodEntryRollup: + "A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.", + CyclePrediction: + "A forecast derived from `MenstrualCycle` history; regenerated on the next cycle read.", + StrainTrimpCache: + "A cache of a pure function over `Workout` and heart-rate samples.", + WorkoutInsight: + "Generated narrative over `Workout`; regenerates on demand. Carrying it would restore an interpretation of data rather than the data.", + WorkoutInsightGenerationClaim: + "A concurrency claim guarding the generator above. Meaningless outside the run that took it.", + InsightNarrative: + "Generated prose over measurements. Same reasoning as WorkoutInsight — the record restores, the essay about it does not.", + DocumentContentIndex: + "A search index over `InboundDocument` text; rebuilt by the indexer.", + DocumentThumbnail: + "Rendered from the stored document bytes on demand, and those bytes are carried, so the picture rebuilds itself on first view.", + ArrivalReaction: + "Derived from what changed since the last visit; recomputed per visit.", + DismissedPriorityItem: + "A per-item dismissal of a recomputed priority list. The list regenerates; a stale dismissal would suppress an item the new list means to raise.", +}; + +/** + * Deliberately excluded, with the reason. These are the entries most likely to + * be argued with later, so each says what it would cost to include. + */ +export const NOT_IN_BACKUP_MODELS: Readonly> = { + // Provider credentials and handshake state. + WithingsConnection: + "An OAuth grant bound to this deployment's client registration. Restored elsewhere it is a dead token that makes the integration look connected while it silently fails — the exact shape of defect this release is about.", + WithingsOAuthState: + "A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.", + WhoopConnection: + "An OAuth grant bound to this deployment's client registration; on another host it is a dead token that reads as connected.", + WhoopOAuthState: + "A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.", + WhoopConnectTicket: + "A single-use ticket that binds one connect attempt to one browser; spent or expired long before a restore.", + FitbitConnection: + "An OAuth grant bound to this deployment's client registration; on another host it is a dead token that reads as connected.", + FitbitOAuthState: + "A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.", + GoogleHealthConnection: + "An OAuth grant bound to this deployment's client registration; on another host it is a dead token that reads as connected.", + GoogleHealthOAuthState: + "A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.", + McpOAuthConnection: + "A grant issued to a client that was registered against this instance.", + IntegrationStatus: + "A ledger of what this deployment's syncs did. Restoring it onto another host would assert a sync history that host never had.", + + // Credentials and device identity. + ApiToken: + "Bearer credentials. A backup file is not a credential store, and a leaked file must not become a set of working tokens.", + Device: + "An APNs token identifying one install on one phone. Restored elsewhere it addresses a device that will never receive from this host.", + PushSubscription: + "A Web Push endpoint issued by one browser for one origin. Another host cannot send to it, and the browser that owns it re-subscribes on its own.", + NotificationChannel: + "Carries channel secrets (Telegram chat binding, ntfy topic). Same reasoning as ApiToken.", + UserKnownDevice: + "A device-recognition ledger. Restoring it would pre-trust devices on a host that has never seen them.", + StepUpElevation: + "Proof that a second factor was satisfied minutes ago. Carrying it would let a restored file re-grant an elevation nobody performed.", + + // Ledgers whose meaning is local to the deployment. + PushAttempt: + "A 90-day delivery ledger for this host's sends. It documents what happened here.", + AuditLog: + "An append-only record of actions taken on this instance. Merging one instance's audit trail into another's would make the trail untrue.", + CoachUsage: + "Spend metering against this operator's own AI budget. Restoring it would charge one instance's consumption against another instance's cap.", + ProviderHealth: + "What this host observed when it called its providers. Another host has its own network, keys and rate limits, so the observation does not transfer.", + IdempotencyKey: + "Replay protection for requests this host already answered. Restoring it would suppress a legitimate new request.", + DataBackup: + "The backup catalogue itself. A backup that contains the list of backups is a recursion with no reader.", + ImportJob: + "A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.", + MedicationIntakeImportJob: + "A job record pointing at an uploaded dose-history file that is not carried in the backup, so restoring the job would resurrect a task with nothing to work on.", + TelegramPromptContext: + "Records which reminder a Telegram reply refers to, keyed by a message id in one chat on one bot. Meaningless against any other chat, and the conversation it belongs to is not carried either.", + + // Sharing and outbound links. + ClinicianShareLink: + "A live URL handed to a practice. Restoring it would resurrect a link the account may have revoked deliberately — and it points at a host that may not be this one.", + ClinicianShareLinkDocument: + "Names which documents a share link exposed. Without the link it grants nothing, and with it, it would re-expose files through a URL the account may have revoked.", + + // Transient notification bookkeeping. + MoodReminderDispatch: + "A per-day record of whether this host already sent today's prompt. The next day writes its own, and a restored one would suppress a send.", + TelegramScheduledDeletion: + "A pending delete addressed at message ids in one chat on one bot. Those ids mean something else, or nothing, anywhere the messages do not exist.", + TelegramReminderMessage: + "Message ids in one chat on one bot, used to edit or clean up a sent reminder. They address messages that a restored instance never sent.", + RecommendationFeedback: + "Feedback on generated recommendations that are themselves DERIVED. Restoring the feedback without the item it judged leaves an orphan.", +}; + +/** Auth material: not user data, and excluded from the wipe for the same reason. */ +export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [ + "Session", + "RefreshToken", + "Passkey", + "WebauthnMfaCredential", + "MfaRecoveryCode", + "MfaChallenge", + "AuthChallenge", + "TrustedDevice", + "OidcNativeHandoff", + "InviteRedemption", +]; + +export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH"; + +/** + * The verdict for a model, or `null` when the schema carries a model this file + * has never ruled on. The guard turns that `null` into a failing test, which + * is the whole point: a new model gets a decision, not a default. + */ +export function backupVerdict(model: string): BackupVerdict | null { + if ((BACKED_UP_MODELS as readonly string[]).includes(model)) { + return "BACKED_UP"; + } + if (model in DERIVED_MODELS) return "DERIVED"; + if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP"; + if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH"; + return null; +} From b2e19ce0b6204915e94b81e7518d18c64849a26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 11:22:22 +0200 Subject: [PATCH 12/39] fix(backup): carry the symptoms an account made, and stop dropping the ones it cannot find MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restoreCycleData was loud about one missing reference and silent about another, three lines apart. An unknown cycle reference threw; an unknown symptom key was filtered out of the list, so the day-log came back with one of its symptoms quietly gone and the restore reported success. The cause was upstream: the seeded catalogue is reference data every instance has, but a symptom the user created lives only in their account and was never carried. The key had nothing to resolve against, and the filter turned that into silence. Both ends now. The account's own definitions travel with the backup, the seeded rows deliberately do not — shipping those would let one instance's restore rewrite another's reference data. And the resolve step throws on an unresolvable key rather than filtering it, so the same function answers the same question the same way twice. Widening the builder's client type made four export tests fail on a missing delegate, which is the sweep working: those are the call sites, named by the compiler instead of found by reading. --- .../export/__tests__/per-type-routes.test.ts | 3 + .../export/encrypted/__tests__/route.test.ts | 1 + src/lib/cycle/__tests__/backup.test.ts | 78 ++++++++++++++++ src/lib/cycle/backup.ts | 89 +++++++++++++++++-- src/lib/validations/backup.ts | 24 +++++ 5 files changed, 189 insertions(+), 6 deletions(-) diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index fd351fa8a..81962cb5c 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, cycleDayLog: { findMany: vi.fn() }, + cycleSymptom: { findMany: vi.fn() }, // v1.28 backup-completeness — the records section the full-backup // helper now also reads (`buildRecordsBackupSection`). labResult: { findMany: vi.fn() }, @@ -408,6 +409,7 @@ describe("GET /api/export/full-backup", () => { vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); @@ -476,6 +478,7 @@ describe("GET /api/export/full-backup", () => { vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never); const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); vi.mocked(prisma.labResult.findMany).mockResolvedValue([ diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 6a409c51d..8aaf39708 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + cycleSymptom: { findMany: vi.fn().mockResolvedValue([]) }, // v1.28 backup-completeness — the records section the shared payload // builder now also reads (`buildRecordsBackupSection`). labResult: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index 170d3c29a..7a0166fd1 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -26,6 +26,19 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { updatedAt, }), }, + cycleSymptom: { + findMany: vi.fn().mockResolvedValue([ + { + id: "sym-custom", + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + icon: "Activity", + sortOrder: 3, + isActive: true, + }, + ]), + }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([ { @@ -151,3 +164,68 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { }); }); }); + +describe("a symptom the account created survives the round trip", () => { + /** + * The defect this pins: the seeded symptom catalogue is reference data every + * instance already has, but a symptom the user made exists only in their + * account. It was never carried, so on restore its key resolved to nothing — + * and the link was silently filtered out. The day-log came back, one of its + * symptoms did not, and the restore reported success. + */ + function client(customSymptoms: unknown[]) { + return { + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + cycleSymptom: { findMany: vi.fn().mockResolvedValue(customSymptoms) }, + } as never; + } + + it("carries the account's own symptom definitions", async () => { + const section = await buildCycleBackupSection( + client([ + { + id: "sym-1", + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + icon: null, + sortOrder: 2, + isActive: true, + }, + ]), + "user-1", + { purpose: "disaster-recovery" }, + ); + + expect(section.customSymptoms).toHaveLength(1); + expect(section.customSymptoms[0]).toMatchObject({ + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + sortOrder: 2, + isActive: true, + }); + }); + + it("asks only for the account's own rows, never the seeded catalogue", async () => { + // Carrying the seeded rows would let one instance's restore rewrite + // another instance's reference data. + const c = client([]); + await buildCycleBackupSection(c, "user-1", {}); + expect( + (c as unknown as { cycleSymptom: { findMany: ReturnType } }) + .cycleSymptom.findMany, + ).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + }); + + it("writes an empty list rather than omitting the section", async () => { + // An absent key and an empty list read the same to a careless consumer. + // The restore distinguishes them, so the builder must be explicit. + const section = await buildCycleBackupSection(client([]), "user-1", {}); + expect(section.customSymptoms).toEqual([]); + }); +}); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 6affd2607..1440d72c1 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -92,6 +92,23 @@ export interface CycleBackupSection { updatedAt?: string; symptomKeys: string[]; }>; + /** + * The account's OWN symptom definitions. + * + * The seeded catalogue is reference data every instance already has, but a + * symptom the user created exists only here. Without it the restore looks up + * a key that resolves to nothing and drops the link — the day-log comes back + * with one of its symptoms quietly missing. + */ + customSymptoms: Array<{ + id?: string; + key: string; + labelKey: string; + categoryId: string; + icon: string | null; + sortOrder: number; + isActive: boolean; + }>; } /** @@ -100,12 +117,15 @@ export interface CycleBackupSection { * client) so both writers share one read. */ export async function buildCycleBackupSection( - prisma: Pick, + prisma: Pick< + PrismaClient, + "cycleProfile" | "menstrualCycle" | "cycleDayLog" | "cycleSymptom" + >, userId: string, options: CycleBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; - const [profile, cycles, dayLogs] = await Promise.all([ + const [profile, cycles, dayLogs, customSymptoms] = await Promise.all([ prisma.cycleProfile.findUnique({ where: { userId } }), prisma.menstrualCycle.findMany({ where: disasterRecovery @@ -120,6 +140,12 @@ export async function buildCycleBackupSection( symptomLinks: { include: { symptom: { select: { key: true } } } }, }, }), + // `userId` set means the user made it. The NULL rows are the seeded + // catalogue, which the restoring instance already has. + prisma.cycleSymptom.findMany({ + where: { userId }, + orderBy: { key: "asc" }, + }), ]); return { @@ -196,6 +222,15 @@ export async function buildCycleBackupSection( tz: d.tz, symptomKeys: d.symptomLinks.map((l) => l.symptom.key), })), + customSymptoms: customSymptoms.map((sym) => ({ + ...(disasterRecovery ? { id: sym.id } : {}), + key: sym.key, + labelKey: sym.labelKey, + categoryId: sym.categoryId, + icon: sym.icon, + sortOrder: sym.sortOrder, + isActive: sym.isActive, + })), }; } @@ -353,7 +388,30 @@ export async function restoreCycleData( restoredCycleIds.add(created.id); } - // Resolve the seeded symptom catalogue once for the link re-creation. + // Re-create the account's own symptom definitions BEFORE resolving links. + // The seeded catalogue is already on this instance; a symptom the user made + // exists only in the file, and a link cannot resolve to a row that was never + // written back. + for (const sym of payload.customSymptoms ?? []) { + await tx.cycleSymptom.upsert({ + where: { key: sym.key }, + create: { + ...(sym.id ? { id: sym.id } : {}), + userId: ownerId, + key: sym.key, + labelKey: sym.labelKey, + categoryId: sym.categoryId, + icon: sym.icon ?? null, + sortOrder: sym.sortOrder, + isActive: sym.isActive, + }, + // `key` is globally unique, so a seeded key would collide. Leave the + // catalogue row alone: the account's links resolve against it either way. + update: {}, + }); + } + + // Resolve the symptom catalogue once for the link re-creation. const allKeys = Array.from( new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])), ); @@ -367,6 +425,20 @@ export async function restoreCycleData( select: { id: true, key: true }, }); for (const r of rows) symptomIdByKey.set(r.key, r.id); + + // Three lines below, an unknown CYCLE reference throws. An unknown symptom + // used to be filtered out instead: the day-log came back with one of its + // symptoms quietly missing, and the restore reported success. Same + // function, same class of missing reference, two different answers — so + // this one is loud now too. + const unresolved = allKeys.filter((k) => !symptomIdByKey.has(k)); + if (unresolved.length > 0) { + throw new Error( + `Unknown cycle symptom keys: ${unresolved.join(", ")}. ` + + "The backup references symptoms that are neither in this instance's " + + "seeded catalogue nor carried in the file.", + ); + } } // Recreate day-logs (with symptom links). The owning cycle is the latest @@ -384,9 +456,14 @@ export async function restoreCycleData( }; for (const d of payload.cycleDayLogs) { - const symptomIds = (d.symptomKeys ?? []) - .map((k) => symptomIdByKey.get(k)) - .filter((v): v is string => v !== undefined); + // Every key was proven resolvable above, so this maps rather than filters: + // a `.filter(Boolean)` here would silently re-open the hole that check + // just closed. + const symptomIds = (d.symptomKeys ?? []).map((k) => { + const id = symptomIdByKey.get(k); + if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`); + return id; + }); const cycleId = d.cycleId !== undefined ? d.cycleId === null diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 854b1a7be..b204181b3 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -314,6 +314,27 @@ const cycleProfileSchema = z }) .passthrough(); +/** + * A symptom the account created, as opposed to the seeded catalogue. + * + * Carried because a day-log's symptom link resolves by key: without the + * definition the key resolves to nothing on the restoring instance, and the + * link is lost. The seeded rows are deliberately NOT carried — every instance + * already has them, and shipping them would mean a restore could rewrite + * another instance's reference data. + */ +const customCycleSymptomSchema = z + .object({ + id: z.string().min(1).optional(), + key: z.string().min(1), + labelKey: z.string().min(1), + categoryId: z.string().min(1), + icon: z.string().nullable().optional(), + sortOrder: z.number().int().default(0), + isActive: z.boolean().default(true), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -561,6 +582,9 @@ export const backupPayloadSchema = z cycleProfile: cycleProfileSchema.nullable().default(null), cycles: z.array(cycleSpanSchema).default([]), cycleDayLogs: z.array(cycleDayLogSchema).default([]), + // Defaulted rather than required: files written before this field existed + // are still valid, and an account with no custom symptoms writes []. + customSymptoms: z.array(customCycleSymptomSchema).default([]), // Structured records default to empty arrays so older backups remain // parseable. Canonical DR writers add stable ids and encrypted document // fields; portable exports retain the metadata-only subset. From d9afb23067a93aa27eb16d89b6ad7cccd908f476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 12:02:01 +0200 Subject: [PATCH 13/39] fix(backup): restore the nutrient day totals the export has always carried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export has written nutrient day totals since v1.29. The canonical schema never declared the field, so `.passthrough()` carried it through parsing and no reader ever looked at it: every water and vitamin total in the file was discarded on restore, and the restore reported success. Declared in the contract, cleared and re-created beside its siblings, and counted in both places that count — what the file holds and what the restore replaced. A count that stops at the domains someone remembered is how this stayed quiet. --- .../api/admin/backups/[id]/restore/route.ts | 22 ++++++++++++++++++ src/lib/validations/backup.ts | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 879a0a42e..2f5660421 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -58,6 +58,7 @@ interface RestoreResponse { cycleProfile: number; labResults: number; biomarkers: number; + nutrientDays: number; illnessEpisodes: number; allergies: number; familyHistory: number; @@ -328,6 +329,12 @@ const handler = apiHandler( const biomarkers = await tx.biomarker.deleteMany({ where: { userId: ownerId }, }); + // The export has written nutrient day totals since v1.29 and nothing + // here ever read them back: water and vitamin history was carried in + // the file, discarded on restore, and the restore reported success. + const nutrientDays = await tx.nutrientIntakeDay.deleteMany({ + where: { userId: ownerId }, + }); if (payload.appSettings) { const settings = payload.appSettings; @@ -968,6 +975,19 @@ const handler = apiHandler( }); } + if (payload.nutrientDays.length > 0) { + await tx.nutrientIntakeDay.createMany({ + data: payload.nutrientDays.map((n) => ({ + userId: ownerId, + day: n.day, + nutrient: n.nutrient, + amount: n.amount, + unit: n.unit, + source: n.source, + })), + }); + } + if (payload.documents.length > 0) { await tx.inboundDocument.createMany({ data: payload.documents.map((document) => ({ @@ -1018,6 +1038,7 @@ const handler = apiHandler( cycleProfile: cycleCleared.cycleProfile, labResults: labResults.count, biomarkers: biomarkers.count, + nutrientDays: nutrientDays.count, illnessEpisodes: illnessEpisodes.count, allergies: allergies.count, familyHistory: familyHistory.count, @@ -1140,6 +1161,7 @@ const handler = apiHandler( cycleDayLogs: summary.cycleDayLogs, labResults: summary.labResults, biomarkers: summary.biomarkers, + nutrientDays: summary.nutrientDays, illnessEpisodes: summary.illnessEpisodes, illnessDayLogs: summary.illnessDayLogs, allergies: summary.allergies, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b204181b3..0af8a3c5b 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -314,6 +314,26 @@ const cycleProfileSchema = z }) .passthrough(); +/** + * A day's total for one nutrient, from one source. + * + * The export has written these since v1.29 and the canonical schema never + * declared them, so `.passthrough()` carried the key through parsing and no + * reader ever looked at it. The restore therefore dropped every water and + * vitamin total on the floor while reporting success. + */ +const nutrientDaySchema = z + .object({ + day: z.string().min(1), + nutrient: z.string().min(1), + amount: z.number(), + unit: z.string().min(1), + // Closed set in app code rather than a DB enum; kept as a string here so a + // future source does not make an old file unparseable. + source: z.string().min(1), + }) + .passthrough(); + /** * A symptom the account created, as opposed to the seeded catalogue. * @@ -595,6 +615,7 @@ export const backupPayloadSchema = z familyHistory: z.array(familyHistoryBackupSchema).default([]), workouts: z.array(workoutBackupSchema).default([]), documents: z.array(documentBackupSchema).default([]), + nutrientDays: z.array(nutrientDaySchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() @@ -632,6 +653,7 @@ export interface BackupSummary { cycleDayLogs: number; /** Lab results in the backup. */ labResults: number; + nutrientDays: number; /** User-scoped biomarker catalog entries in the backup. */ biomarkers: number; /** Illness episodes, including flares/exacerbations. */ @@ -659,6 +681,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.length, + nutrientDays: payload.nutrientDays.length, labResults: payload.labResults.length, biomarkers: payload.biomarkers.length, illnessEpisodes: payload.illnessEpisodes.length, From cd30cb3a52b4d4db02b82bfeb09c67fc2538cdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 12:41:45 +0200 Subject: [PATCH 14/39] fix(backup): carry the mood tags an account made, so its own file can be restored The restore resolves a mood entry's rated factors by key and asks only for the seeded catalogue. A tag the user created is not in it, so the lookup came up short and the whole restore threw 'Unknown mood factor keys'. The file was complete; it simply could not be read back. That is the same cause as the cycle symptoms two commits ago, with the opposite ending. There, an unresolvable key was filtered out and the loss was silent. Here it stopped everything. One shape of missing definition, two answers, neither of them the right one. The account's own tag definitions travel now, get written before the factors resolve, and the lookup accepts the seeded catalogue or the owner's own. The seeded rows stay out of the file for the same reason as before: a restore must not rewrite another instance's reference data. Four more test mocks needed the new delegate, which is again the compiler and the suite naming the call sites rather than me hunting for them. --- .../api/admin/backups/[id]/restore/route.ts | 25 ++++++++++++++++++- .../export/__tests__/per-type-routes.test.ts | 4 +++ .../__tests__/soft-delete-filter.test.ts | 1 + .../export/encrypted/__tests__/route.test.ts | 1 + .../__tests__/full-backup-payload.test.ts | 1 + src/lib/export/full-backup-payload.ts | 18 +++++++++++++ src/lib/validations/backup.ts | 21 ++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 2f5660421..17d2ca600 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -605,6 +605,28 @@ const handler = apiHandler( ), ), ]; + // Re-create the account's own tag definitions first. The lookup + // below used to ask only for the seeded catalogue (`userId: null`), + // so a single custom rated tag made the whole restore throw + // "Unknown mood factor keys" — the file was complete and unusable. + for (const tag of payload.customMoodTags) { + await tx.moodTag.upsert({ + where: { key: tag.key }, + create: { + ...(tag.id ? { id: tag.id } : {}), + userId: ownerId, + key: tag.key, + labelKey: tag.labelKey, + categoryId: tag.categoryId, + kind: tag.kind, + isActive: tag.isActive, + }, + // `key` is globally unique, so a seeded key would collide. + // Leave the catalogue row alone; the links resolve either way. + update: {}, + }); + } + const factorRows = factorKeys.length === 0 ? [] @@ -612,7 +634,8 @@ const handler = apiHandler( where: { key: { in: factorKeys }, kind: "RATED", - userId: null, + // The seeded catalogue OR this account's own tags. + OR: [{ userId: null }, { userId: ownerId }], }, select: { id: true, key: true }, }); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 81962cb5c..70e0f0fd6 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + moodTag: { findMany: vi.fn() }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, @@ -361,6 +362,7 @@ describe("GET /api/export/mood", () => { remaining: 9, resetAt: Date.now() + 3600_000, }); + vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: "2026-05-01", @@ -406,6 +408,7 @@ describe("GET /api/export/full-backup", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); @@ -475,6 +478,7 @@ describe("GET /api/export/full-backup", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 0f039db61..4544f74f9 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, moodEntry: { findMany: vi.fn(), count: vi.fn() }, + moodTag: { findMany: vi.fn() }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, user: { findUnique: vi.fn() }, passkey: { findMany: vi.fn() }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 8aaf39708..9f7e39fca 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + moodTag: { findMany: vi.fn().mockResolvedValue([]) }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index aac92b01f..b699ff685 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -159,6 +159,7 @@ function makePrisma() { }, ]), }, + moodTag: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 835dade03..5dade4a0d 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -92,6 +92,7 @@ export async function buildFullBackupPayload( medications, intakeEvents, moodEntries, + customMoodTags, cycle, records, nutrientDays, @@ -175,6 +176,15 @@ export async function buildFullBackupPayload( }, }, }), + // The account's OWN mood tags. The seeded catalogue is reference data every + // instance has; a tag the user made lives only here. Without it the restore + // cannot resolve the entry's factor keys — and unlike the cycle path, which + // used to drop them silently, the mood path throws, so an account with one + // custom rated tag could not be restored at all. + prisma.moodTag.findMany({ + where: { userId }, + orderBy: { key: "asc" }, + }), buildCycleBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), @@ -309,6 +319,14 @@ export async function buildFullBackupPayload( rating: link.rating!, })), })), + customMoodTags: customMoodTags.map((tag) => ({ + ...(disasterRecovery ? { id: tag.id } : {}), + key: tag.key, + labelKey: tag.labelKey, + categoryId: tag.categoryId, + kind: tag.kind, + isActive: tag.isActive, + })), cycleProfile: cycle.cycleProfile, cycles: cycle.cycles, cycleDayLogs: cycle.cycleDayLogs, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 0af8a3c5b..0387e2fdf 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -314,6 +314,26 @@ const cycleProfileSchema = z }) .passthrough(); +/** + * A mood tag the account created, as opposed to the seeded catalogue. + * + * Carried for the same reason as a custom cycle symptom, but the failure it + * prevents is louder: the restore resolves an entry's rated factors by key and + * throws on one it cannot find, so an account with a single custom rated tag + * could not be restored at all. + */ +const customMoodTagSchema = z + .object({ + id: z.string().min(1).optional(), + key: z.string().min(1), + labelKey: z.string().min(1), + categoryId: z.string().min(1), + // "BINARY" | "RATED", enforced in app code rather than a DB enum. + kind: z.string().min(1), + isActive: z.boolean().default(true), + }) + .passthrough(); + /** * A day's total for one nutrient, from one source. * @@ -605,6 +625,7 @@ export const backupPayloadSchema = z // Defaulted rather than required: files written before this field existed // are still valid, and an account with no custom symptoms writes []. customSymptoms: z.array(customCycleSymptomSchema).default([]), + customMoodTags: z.array(customMoodTagSchema).default([]), // Structured records default to empty arrays so older backups remain // parseable. Canonical DR writers add stable ids and encrypted document // fields; portable exports retain the metadata-only subset. From fcc4e661dfe796677fd86b0b7147bdf0d7e96ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:48:35 +0200 Subject: [PATCH 15/39] fix(notifications): scope client-managed suppression to the APNs leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `medication.clientManaged` and `measurementReminder.clientManaged` mean the iOS client schedules the reminder locally, so the server should not push it. That is what the OpenAPI description, the settings copy and the iOS contract all say. What the crons did was skip the dispatch entirely, so a user who flipped the switch also lost Telegram, ntfy, webhook, email and Web Push — channels they may well read on a desktop while the phone holding the local schedule sits in a drawer. Move the gate into the dispatcher's channel loop, where it applies to APNs and nothing else. The skip is not counted as an attempt, so `dispatched` keeps meaning "something reached the user", and it stays countable: the same wide-event keys the crons emitted are emitted here, plus a `skipped` row in the push-attempt ledger. The Vorsorge path is the sharper half. It suppressed every channel and then advanced `nextDueAt`, which dropped the reminder out of the preventive-care card and out of the daily digest: a reminder that never reached anyone was recorded as handled. The advance now happens only where it always belonged, after a dispatch that a channel actually delivered. When nothing delivers, the reminder stays overdue and visible. --- .../__tests__/measurement-reminder.test.ts | 53 ++++- src/lib/jobs/measurement-reminder.ts | 33 +-- src/lib/jobs/reminder/mood-cycle-checks.ts | 1 - .../__tests__/client-managed-apns.test.ts | 206 ++++++++++++++++++ src/lib/notifications/client-managed-apns.ts | 79 +++++++ src/lib/notifications/dispatcher.ts | 56 ++++- 6 files changed, 398 insertions(+), 30 deletions(-) create mode 100644 src/lib/notifications/__tests__/client-managed-apns.test.ts create mode 100644 src/lib/notifications/client-managed-apns.ts diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 1e73f40c3..e2936555f 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -11,7 +11,8 @@ * auto-resolve. * - Ledger-free dedup: a successful dispatch advances nextDueAt past * now so the same due cycle never re-fires. - * - clientManaged suppression skips the APNs send but still advances. + * - clientManaged suppresses the APNs leg only: the tick still + * dispatches, and a reminder nothing delivered stays overdue. * * The Prisma surface is stubbed manually to avoid a testcontainer boot. */ @@ -291,8 +292,12 @@ describe("runMeasurementReminderTick", () => { expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); }); - it("suppresses the push under clientManaged but still advances nextDueAt", async () => { - const { prisma, updates } = makePrisma({ + it("still dispatches under clientManaged — the suppression is APNs-only", async () => { + // `measurementReminder.clientManaged` suppresses the server's APNs + // send, which the dispatcher decides per channel. The tick must not + // pre-empt that decision: a user whose phone owns the local banner + // still reads Telegram or email on their desktop. + const { prisma } = makePrisma({ reminders: [ reminder({ measurementType: null, @@ -314,10 +319,44 @@ describe("runMeasurementReminderTick", () => { { dispatch }, ); - expect(summary.skippedClientManaged).toBe(1); - expect(dispatch).not.toHaveBeenCalled(); - expect(updates).toHaveLength(1); - expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(summary.dispatched).toBe(1); + }); + + it("leaves the reminder overdue when the client-managed skip was the only channel", async () => { + // Nothing delivered, so nothing may be recorded as handled: advancing + // `nextDueAt` here dropped the reminder out of the Vorsorge card and + // out of the daily digest, and a preventive-care reminder that never + // fired was filed as done. + const { prisma, updates } = makePrisma({ + reminders: [ + reminder({ + measurementType: null, + user: { + id: "u1", + timezone: TZ, + locale: "de", + notificationPrefs: { measurementReminder: { clientManaged: true } }, + }, + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => ({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + })); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.skippedNoChannel).toBe(1); + expect(summary.dispatched).toBe(0); + expect(updates).toHaveLength(0); }); it("bounds the scan with a nextDueAt filter so the index is used", async () => { diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 6041c8d05..963459283 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -23,9 +23,9 @@ * query in the cron, NOT a hook on the hot iOS batch-ingest path. * Free-text reminders (no `measurementType`) never auto-resolve; * they advance only on a manual satisfy. - * 3. Per-reminder `clientManaged` suppression of the server-side APNs - * (the medication `clientManaged` precedent) is applied for the - * whole user via `notificationPrefs.measurementReminder.clientManaged`. + * 3. `notificationPrefs.measurementReminder.clientManaged` suppresses the + * server-side APNs send only, and it does so in the dispatcher — the + * tick dispatches for every user and lets the cascade decide. */ import type { PrismaClient } from "@/generated/prisma/client"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -35,7 +35,6 @@ import { defaultLocale, locales } from "@/lib/i18n/config"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getEvent } from "@/lib/logging/context"; -import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs"; import { isModuleEnabled } from "@/lib/modules/gate"; import type { ModuleKey } from "@/lib/modules/registry"; import { @@ -91,7 +90,6 @@ export interface MeasurementReminderSummary { skippedNotDue: number; skippedOutsideWindow: number; skippedModuleDisabled: number; - skippedClientManaged: number; skippedNoChannel: number; /** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */ expiredCleaned: number; @@ -204,7 +202,6 @@ export async function runMeasurementReminderTick( skippedNotDue: 0, skippedOutsideWindow: 0, skippedModuleDisabled: 0, - skippedClientManaged: 0, skippedNoChannel: 0, expiredCleaned: 0, failed: 0, @@ -237,7 +234,6 @@ export async function runMeasurementReminderTick( id: true, timezone: true, locale: true, - notificationPrefs: true, }, }, }, @@ -318,20 +314,15 @@ export async function runMeasurementReminderTick( continue; } - // Per-user client-managed suppression (the medication precedent). - if (isMeasurementReminderClientManaged(reminder.user.notificationPrefs)) { - getEvent()?.addMeta( - "measurement_reminder.suppressed_client_managed", - reminder.id, - ); - summary.skippedClientManaged += 1; - // Advance past this cycle anyway so a client-managed reminder does - // not pin the server tick re-evaluating the same overdue slot - // every 15 minutes for the rest of the day. - await advanceNextDue(prisma, reminder, timezone, now); - continue; - } - + // `measurementReminder.clientManaged` is NOT consulted here. It + // suppresses the server's APNs send only — the dispatcher's APNs + // branch owns that decision and emits the + // `measurement_reminder.suppressed_client_managed` annotation per + // skip. The tick used to suppress every channel AND advance + // `nextDueAt`, so a preventive-care reminder that never reached the + // user was filed as handled and vanished from the card and the digest. + // Now, when nothing delivers, the no-channel branch below leaves the + // reminder overdue and visible. const { title, body } = buildMeasurementReminderPayload( reminder.user.locale, reminder.label, diff --git a/src/lib/jobs/reminder/mood-cycle-checks.ts b/src/lib/jobs/reminder/mood-cycle-checks.ts index 9506fd58e..afa03a835 100644 --- a/src/lib/jobs/reminder/mood-cycle-checks.ts +++ b/src/lib/jobs/reminder/mood-cycle-checks.ts @@ -112,7 +112,6 @@ export async function handleMeasurementReminderCheck( auto_resolved: summary.autoResolved, skipped_not_due: summary.skippedNotDue, skipped_outside_window: summary.skippedOutsideWindow, - skipped_client_managed: summary.skippedClientManaged, skipped_no_channel: summary.skippedNoChannel, failed: summary.failed, }, diff --git a/src/lib/notifications/__tests__/client-managed-apns.test.ts b/src/lib/notifications/__tests__/client-managed-apns.test.ts new file mode 100644 index 000000000..9121c89a5 --- /dev/null +++ b/src/lib/notifications/__tests__/client-managed-apns.test.ts @@ -0,0 +1,206 @@ +/** + * Client-managed suppression is APNs-only. + * + * `notificationPrefs.medication.clientManaged` (and its measurement- + * reminder sibling) means "my iPhone schedules this reminder locally, so + * don't push it". It never meant "stop telling me anywhere" — the OpenAPI + * description, the settings copy and the iOS contract all say APNs. The + * crons used to skip the whole dispatch, so a user who flipped the switch + * also lost the Telegram reminder they read on a desktop. + * + * Pinned here: the APNs channel is skipped, every other channel still + * sends, the skip is not counted as a delivery attempt, and the per-skip + * wide-event annotation survives so the suppression stays countable. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findMany: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => s, + decrypt: (s: string) => s, +})); + +const addMetaMock = vi.fn(); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: vi.fn(), + addMeta: (key: string, value: unknown) => addMetaMock(key, value), + addExternalCall: vi.fn(), + setError: vi.fn(), + }), +})); + +const sendViaApnsMock = vi.fn(); +const sendViaTelegramMock = vi.fn(); +const recordPushAttemptMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args), +})); +vi.mock("@/lib/notifications/senders/telegram", () => ({ + sendViaTelegram: (...args: unknown[]) => sendViaTelegramMock(...args), +})); +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), +})); + +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { prisma } from "@/lib/db"; + +function channel(type: "APNS" | "TELEGRAM") { + return { + id: `ch-${type}`, + userId: "u-1", + type, + enabled: true, + config: JSON.stringify({ botToken: "b", chatId: "c" }), + consecutiveFailures: 0, + nextRetryAt: null, + preferences: [], + }; +} + +const MEDICATION_PAYLOAD = { + eventType: "MEDICATION_REMINDER" as const, + userId: "u-1", + title: "t", + message: "m", + metadata: { + medicationId: "med-1", + scheduledAt: "2026-06-15T08:00:00.000Z", + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + channel("APNS"), + channel("TELEGRAM"), + ] as never); + sendViaApnsMock.mockResolvedValue({ ok: true }); + sendViaTelegramMock.mockResolvedValue({ ok: true }); + ( + prisma.notificationChannel.update as unknown as ReturnType + ).mockResolvedValue({ consecutiveFailures: 0 }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("dispatchNotification — client-managed APNs suppression", () => { + it("skips APNs and still delivers over every other channel", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: true } }, + } as never); + + const outcome = await dispatchNotification(MEDICATION_PAYLOAD); + + expect(sendViaApnsMock).not.toHaveBeenCalled(); + expect(sendViaTelegramMock).toHaveBeenCalledTimes(1); + // The skipped channel is not an attempt — `dispatched` has to keep + // meaning "something reached the user". + expect(outcome.channelsAttempted).toBe(1); + expect(outcome.channelsSucceeded).toBe(1); + expect(outcome.dispatched).toBe(true); + }); + + it("keeps the skip countable in the wide event and the ledger", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: true } }, + } as never); + + await dispatchNotification(MEDICATION_PAYLOAD); + + expect(addMetaMock).toHaveBeenCalledWith( + "medication_reminder_suppressed_client_managed", + "med-1:2026-06-15T08:00:00.000Z", + ); + expect(addMetaMock).toHaveBeenCalledWith( + "medication_reminder_suppressed_meta", + { + user_id: "u-1", + medication_id: "med-1", + schedule_id: "", + phase: "", + dose_at: "2026-06-15T08:00:00.000Z", + }, + ); + expect(recordPushAttemptMock).toHaveBeenCalledWith({ + userId: "u-1", + channel: "APNS", + eventType: "MEDICATION_REMINDER", + result: "skipped", + reason: "client_managed", + }); + }); + + it("honours the roaming deliveryDefault mapping", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { deliveryDefault: "client" } }, + } as never); + + await dispatchNotification(MEDICATION_PAYLOAD); + + expect(sendViaApnsMock).not.toHaveBeenCalled(); + }); + + it("suppresses the measurement reminder's APNs leg on its own flag", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { measurementReminder: { clientManaged: true } }, + } as never); + + await dispatchNotification({ + eventType: "MEASUREMENT_REMINDER", + userId: "u-1", + title: "t", + message: "m", + metadata: { reminderId: "rem-1" }, + }); + + expect(sendViaApnsMock).not.toHaveBeenCalled(); + expect(sendViaTelegramMock).toHaveBeenCalledTimes(1); + expect(addMetaMock).toHaveBeenCalledWith( + "measurement_reminder.suppressed_client_managed", + "rem-1", + ); + }); + + it("sends over APNs when the flag is unset", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: null, + } as never); + + await dispatchNotification(MEDICATION_PAYLOAD); + + expect(sendViaApnsMock).toHaveBeenCalledTimes(1); + expect(recordPushAttemptMock).not.toHaveBeenCalled(); + }); + + it("does not gate an event type that has no client-managed opt-out", async () => { + // The medication flag must not leak across event types: a user who + // owns their dose reminders locally still gets everything else. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: true } }, + } as never); + + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(sendViaApnsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/notifications/client-managed-apns.ts b/src/lib/notifications/client-managed-apns.ts new file mode 100644 index 000000000..b63cc38d0 --- /dev/null +++ b/src/lib/notifications/client-managed-apns.ts @@ -0,0 +1,79 @@ +/** + * Client-managed suppression — the APNs leg only. + * + * A user whose iOS client schedules its own local reminders sets + * `notificationPrefs..clientManaged` (or the medication + * `deliveryDefault: "client"` that maps onto it). The published contract, + * the OpenAPI description and every copy site say the same thing: it + * suppresses the server's APNs send. It used to suppress the whole + * dispatch, so a user who flipped it also lost Telegram, ntfy, webhook, + * email and Web Push — channels they may read on a device that is not the + * phone holding the local schedule. + * + * The gate therefore lives here, next to the channel loop, and applies to + * exactly one channel. The per-skip wide-event annotation is preserved so + * the suppression stays countable; the annotation key per category is the + * one the crons emitted before the move. + */ +import { + isMeasurementReminderClientManaged, + isMedicationReminderClientManaged, +} from "@/lib/validations/notification-prefs"; + +interface ClientManagedGate { + /** Reads the category's flag out of the raw `notificationPrefs` blob. */ + isClientManaged: (raw: unknown) => boolean; + /** Wide-event meta key, unchanged from the cron that used to emit it. */ + metaKey: string; + /** Stable per-skip identifier built from the dispatch payload metadata. */ + tag: (metadata: Record | undefined) => string; + /** + * Optional structured companion, emitted under `detailKey`. Same key and + * same shape the cron used to emit, so an operator's existing read of the + * wide event still resolves the medication, schedule and dose instant. + */ + detailKey?: string; + detail?: ( + userId: string, + metadata: Record | undefined, + ) => Record; +} + +function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * Closed map: only these event types honour a client-managed flag. Every + * other event dispatches to APNs unconditionally. Cycle reminders are not + * listed — that cron still resolves its own flag before dispatching. + */ +export const CLIENT_MANAGED_APNS_EVENTS: Record = { + MEDICATION_REMINDER: { + isClientManaged: isMedicationReminderClientManaged, + metaKey: "medication_reminder_suppressed_client_managed", + tag: (metadata) => + `${str(metadata?.medicationId)}:${str(metadata?.scheduledAt)}`, + detailKey: "medication_reminder_suppressed_meta", + detail: (userId, metadata) => ({ + user_id: userId, + medication_id: str(metadata?.medicationId), + schedule_id: str(metadata?.scheduleId), + phase: str(metadata?.phase), + dose_at: str(metadata?.scheduledAt), + }), + }, + MEASUREMENT_REMINDER: { + isClientManaged: isMeasurementReminderClientManaged, + metaKey: "measurement_reminder.suppressed_client_managed", + tag: (metadata) => str(metadata?.reminderId), + }, +}; + +/** + * Does this event type have a client-managed opt-out at all? Cheap check + * the dispatcher runs before paying for the preferences read. + */ +export function hasClientManagedApnsGate(eventType: string): boolean { + return eventType in CLIENT_MANAGED_APNS_EVENTS; +} diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index cab12442e..48336ef85 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -24,6 +24,11 @@ import { recordChannelTransientFailure, } from "@/lib/notifications/channel-state"; import { getEvent } from "@/lib/logging/context"; +import { + CLIENT_MANAGED_APNS_EVENTS, + hasClientManagedApnsGate, +} from "@/lib/notifications/client-managed-apns"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; /** * Dispatch a notification to all enabled channels for a user. @@ -33,7 +38,9 @@ import { getEvent } from "@/lib/logging/context"; * 1. Skip if `enabled=false` (manually or auto-disabled). * 2. Skip if currently in retry-cooldown (`nextRetryAt > now`). * 3. Check the per-channel preference for this eventType (default: enabled). - * 4. Call the appropriate sender — which now returns a `SendOutcome` so + * 4. Skip the APNs channel — and only that one — when the user's iOS + * client owns the local reminder for this event type. + * 5. Call the appropriate sender — which now returns a `SendOutcome` so * the dispatcher can classify hard rejects (410, blocked-by-user) vs * soft errors (5xx, 429, network) and update channel state accordingly. * @@ -156,6 +163,26 @@ export async function dispatchNotification( }); defaultEnabled = user?.moodReminderEnabled === true; } + // Client-managed APNs suppression, resolved at most once per dispatch + // and only when an APNs channel is actually in the cascade. `null` + // means "not applicable"; the read is lazy so the common event types + // pay nothing for it. + let clientManagedApns: boolean | null = null; + const resolveClientManagedApns = async (): Promise => { + if (clientManagedApns !== null) return clientManagedApns; + const gate = CLIENT_MANAGED_APNS_EVENTS[payload.eventType]; + if (!gate) { + clientManagedApns = false; + return false; + } + const row = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { notificationPrefs: true }, + }); + clientManagedApns = gate.isClientManaged(row?.notificationPrefs); + return clientManagedApns; + }; + for (const channel of channels) { const pref = channel.preferences[0]; if (pref) { @@ -176,6 +203,33 @@ export async function dispatchNotification( continue; } + // The iOS client owns the local banner for this event type — skip + // the server's APNs send and nothing else. Not counted as an + // attempt: no send was made, so `dispatched` keeps telling the truth + // about whether anything actually reached the user. + if ( + channel.type === "APNS" && + hasClientManagedApnsGate(payload.eventType) && + (await resolveClientManagedApns()) + ) { + const gate = CLIENT_MANAGED_APNS_EVENTS[payload.eventType]; + getEvent()?.addMeta(gate.metaKey, gate.tag(payload.metadata)); + if (gate.detail && gate.detailKey) { + getEvent()?.addMeta( + gate.detailKey, + gate.detail(payload.userId, payload.metadata), + ); + } + recordPushAttempt({ + userId: payload.userId, + channel: "APNS", + eventType: payload.eventType, + result: "skipped", + reason: "client_managed", + }); + continue; + } + channelsAttempted += 1; try { From c6b944b7fbae5f2f30844da0fcc15e8bab1176b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:48:47 +0200 Subject: [PATCH 16/39] fix(reminders): send a dose reminder once per slot, phase and day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-phase "already sent today" guard read the Telegram message ledger, and that table is written only when a Telegram send succeeds. A user on email, APNs or ntfy never had a row, so the 15-minute tick re-derived the same overdue slot and sent it again on every pass — around fifty mails for a single missed dose. Anchor the guard in `push_attempts` instead, the ledger the daily briefing and the illness escalation already use. Two properties are deliberate: the anchor is stamped for every dispatched slot regardless of what any channel returned, because keying it on a delivered push would re-open the flood the moment a channel starts failing; and it is stamped before the dispatch, so a crash costs one phase's notice rather than a repeat every quarter hour. The key carries the schedule id and the slot's real HH:mm alongside the phase and the local date. Both are load-bearing: two legacy single-window schedules on one medication share the empty first-class time-of-day, so a coarser key would collapse the evening dose into the morning one. The tests assert the other direction too — the next phase still escalates, and the next local day starts clean. The Telegram ledger keeps its message-tracking duties; it is no longer the dedup key. Anchor rows carry a sentinel channel and stay out of the operator's notification health and diagnostic reads, which count deliveries, not decisions. --- .../diagnostic/__tests__/route.test.ts | 17 + .../admin/notifications/diagnostic/route.ts | 6 +- .../health/__tests__/route.test.ts | 12 + .../api/admin/notifications/health/route.ts | 9 +- .../medication-reminder-dedup.test.ts | 314 ++++++++++++++++++ .../reminder/medication-reminder-check.ts | 87 +++-- src/lib/notifications/reminder-dedup.ts | 119 +++++++ 7 files changed, 518 insertions(+), 46 deletions(-) create mode 100644 src/lib/jobs/__tests__/medication-reminder-dedup.test.ts create mode 100644 src/lib/notifications/reminder-dedup.ts diff --git a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts index 1f28468ba..8581ebece 100644 --- a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts @@ -68,6 +68,7 @@ vi.mock("@/lib/crypto", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; const ADMIN_USER_ID = "admin-1"; const ADMIN_CTX = { @@ -393,4 +394,20 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => expect(args.orderBy).toEqual({ createdAt: "desc" }); expect(args.take).toBe(20); }); + + it("excludes reminder-dedup bookkeeping rows so the last 20 are real sends", async () => { + // The medication tick anchors its per-slot dedup in this table under a + // sentinel channel. Letting those rows into the trailing window would + // push the actual delivery attempts — the thing an operator opens this + // endpoint for — off the list. + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never); + + await GET(); + + const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as { + where: { channel?: { not?: string } }; + }; + expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL }); + }); }); diff --git a/src/app/api/admin/notifications/diagnostic/route.ts b/src/app/api/admin/notifications/diagnostic/route.ts index 288048d62..9c80e4df9 100644 --- a/src/app/api/admin/notifications/diagnostic/route.ts +++ b/src/app/api/admin/notifications/diagnostic/route.ts @@ -4,6 +4,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { decrypt } from "@/lib/crypto"; import type { ChannelType } from "@/lib/notifications/types"; +import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; export const dynamic = "force-dynamic"; @@ -174,7 +175,10 @@ export const GET = apiHandler(async () => { // turns the orderBy + take into a single index scan, so the cost is // bounded regardless of the user's lifetime push volume. prisma.pushAttempt.findMany({ - where: { userId: user.id }, + // The reminder tick's per-slot dedup anchors live in this table under + // a sentinel channel. They are bookkeeping, not delivery attempts, + // and would push the real sends out of the trailing window. + where: { userId: user.id, channel: { not: REMINDER_DEDUP_CHANNEL } }, orderBy: { createdAt: "desc" }, take: 20, select: { diff --git a/src/app/api/admin/notifications/health/__tests__/route.test.ts b/src/app/api/admin/notifications/health/__tests__/route.test.ts index 319776185..a431abc2d 100644 --- a/src/app/api/admin/notifications/health/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/health/__tests__/route.test.ts @@ -37,6 +37,7 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); import { GET } from "../route"; import { prisma } from "@/lib/db"; import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; import { NextRequest } from "next/server"; const ADMIN_CTX = { @@ -139,6 +140,17 @@ describe("GET /api/admin/notifications/health — aggregate", () => { ]); }); + it("excludes reminder-dedup bookkeeping rows from the channel picture", async () => { + // The medication tick anchors its per-slot dedup in the same table + // under a sentinel channel. Those rows are not delivery attempts, so + // counting them would misreport what the channels are doing. + await GET(req()); + const args = vi.mocked(prisma.pushAttempt.groupBy).mock.calls[0][0] as { + where: { channel?: { not?: string } }; + }; + expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL }); + }); + it("defaults the window to 24h and clamps out-of-range values", async () => { let res = await GET(req("999")); let body = (await res.json()) as HealthEnvelope; diff --git a/src/app/api/admin/notifications/health/route.ts b/src/app/api/admin/notifications/health/route.ts index a4fc3825a..47460a9f5 100644 --- a/src/app/api/admin/notifications/health/route.ts +++ b/src/app/api/admin/notifications/health/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; export const dynamic = "force-dynamic"; @@ -58,7 +59,13 @@ export const GET = apiHandler(async (request) => { const [grouped, disabled] = await Promise.all([ prisma.pushAttempt.groupBy({ by: ["channel", "result"], - where: { createdAt: { gte: since } }, + // The reminder tick anchors its per-slot dedup in the same table + // under a sentinel channel. Those rows record a decision, not a + // delivery, so they stay out of the per-channel health picture. + where: { + createdAt: { gte: since }, + channel: { not: REMINDER_DEDUP_CHANNEL }, + }, _count: { _all: true }, }), // Channels disabled with an automated reason. A manual disable leaves diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts new file mode 100644 index 000000000..b212c4a2c --- /dev/null +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -0,0 +1,314 @@ +/** + * Medication-reminder dedup + client-managed contract. + * + * Two behaviours are pinned here, both of them user-visible failures + * before this suite existed: + * + * 1. **One dispatch per (medication, slot, phase, local day).** The + * dedup used to key on the Telegram message ledger, which is written + * only when a Telegram send succeeds. A user on email / APNs / ntfy + * therefore never had a ledger row, so the 15-minute tick re-fired the + * same overdue notice for the rest of the day — roughly fifty mails per + * missed dose. The anchor now lives in `push_attempts` and is written + * for every dispatched slot regardless of channel outcome. + * + * 2. **Coarser must not mean quieter.** The dedup is per phase and per + * local day: the ORANGE → RED escalation still fires, and the next + * day starts clean. That direction is the whole risk of the fix, so it + * is asserted explicitly rather than implied. + * + * The tick reads the wall clock itself, so the fixture drives it with a + * faked system time and a hand-rolled Prisma surface (no testcontainer). + * All instants are UTC and the fixture user's timezone is UTC, so the + * suite is identical under any `TZ`. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +interface FakePushAttempt { + userId: string; + channel: string; + eventType: string; + result: string; + reason: string | null; + createdAt: Date; +} + +const pushAttempts: FakePushAttempt[] = []; + +const prismaMock = { + telegramScheduledDeletion: { + findMany: vi.fn().mockResolvedValue([]), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + telegramPromptContext: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + medication: { + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + findMany: vi.fn().mockResolvedValue([]), + }, + medicationIntakeEvent: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + count: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue({}), + }, + telegramReminderMessage: { + findUnique: vi.fn().mockResolvedValue(null), + }, + pushAttempt: { + create: vi.fn( + async ({ data }: { data: Omit }) => { + pushAttempts.push({ ...data, createdAt: new Date() }); + return { id: `pa-${pushAttempts.length}` }; + }, + ), + findFirst: vi.fn( + async ({ + where, + }: { + where: { + userId: string; + eventType: string; + reason: string; + createdAt?: { gte: Date }; + }; + }) => + pushAttempts.find( + (row) => + row.userId === where.userId && + row.eventType === where.eventType && + row.reason === where.reason && + (where.createdAt?.gte === undefined || + row.createdAt >= where.createdAt.gte), + ) ?? null, + ), + }, + user: { + findFirst: vi.fn().mockResolvedValue(null), + }, +}; + +vi.mock("@/lib/jobs/reminder/shared", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getWorkerPrisma: () => prismaMock, + workerLog: vi.fn(), + }; +}); +vi.mock("@/lib/jobs/worker-status", () => ({ + recordError: vi.fn(), + recordReminderCheck: vi.fn(), + markWorkerStarted: vi.fn(), +})); +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: vi.fn( + async ( + _name: string, + fn: (evt: { + addMeta: (k: string, v: unknown) => void; + addWarning: (m: string) => void; + setError: (e: unknown) => void; + }) => Promise, + ) => fn({ addMeta: vi.fn(), addWarning: vi.fn(), setError: vi.fn() }), + ), +})); +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }), +})); +vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); +vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + +import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +/** One 08:00–09:00 slot, every day, notifications on, no Telegram anywhere. */ +function medicationFixture(notificationPrefs: unknown = null) { + return { + id: "med-1", + name: "Fixture", + dose: "1", + active: true, + asNeeded: false, + notificationsEnabled: true, + snoozedUntil: null, + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + phaseConfig: null, + schedules: [ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: [], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + dose: null, + }, + ], + user: { + id: "user-1", + timezone: "UTC", + locale: "en", + notificationPrefs, + }, + }; +} + +/** Phases dispatched so far, in order. */ +function dispatchedPhases(): string[] { + return vi + .mocked(dispatchNotification) + .mock.calls.map( + (call) => + (call[0].metadata as { phase: string } | undefined)?.phase ?? "", + ); +} + +async function tickAt(iso: string): Promise { + vi.setSystemTime(new Date(iso)); + await handleReminderCheck([]); +} + +beforeEach(() => { + vi.clearAllMocks(); + pushAttempts.length = 0; + vi.useFakeTimers(); + prismaMock.telegramScheduledDeletion.findMany.mockResolvedValue([]); + prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.medication.updateMany.mockResolvedValue({ count: 0 }); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.count.mockResolvedValue(0); + prismaMock.telegramReminderMessage.findUnique.mockResolvedValue(null); + vi.mocked(dispatchNotification).mockResolvedValue({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("medication reminder — one dispatch per slot, phase and local day", () => { + it("dispatches the overdue notice once across repeated ticks in the same phase", async () => { + prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]); + + // Four ticks, 15 minutes apart, all inside the ORANGE phase of the + // 08:00–09:00 slot. The pre-fix worker sent one notification per tick. + await tickAt("2026-06-15T09:15:00Z"); + await tickAt("2026-06-15T09:30:00Z"); + await tickAt("2026-06-15T09:45:00Z"); + await tickAt("2026-06-15T10:00:00Z"); + + expect(dispatchedPhases()).toEqual(["ORANGE"]); + }); + + it("still escalates to the next phase on the same day", async () => { + prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]); + + await tickAt("2026-06-15T09:15:00Z"); // ORANGE + await tickAt("2026-06-15T09:30:00Z"); // ORANGE again — suppressed + await tickAt("2026-06-15T13:00:00Z"); // 240 min past window end → RED + + expect(dispatchedPhases()).toEqual(["ORANGE", "RED"]); + }); + + it("starts clean on the next local day", async () => { + prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]); + + await tickAt("2026-06-15T09:15:00Z"); + await tickAt("2026-06-15T13:00:00Z"); + await tickAt("2026-06-16T09:15:00Z"); + await tickAt("2026-06-16T09:30:00Z"); + + expect(dispatchedPhases()).toEqual(["ORANGE", "RED", "ORANGE"]); + }); + + it("suppresses the repeat even when no channel delivered", async () => { + // The anchor is written per dispatched slot, not per successful send. + // Anchoring on success would re-fire every 15 minutes against a broken + // channel — the same flood wearing a different key. + prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]); + vi.mocked(dispatchNotification).mockResolvedValue({ + dispatched: false, + channelsAttempted: 1, + channelsSucceeded: 0, + }); + + await tickAt("2026-06-15T09:15:00Z"); + await tickAt("2026-06-15T09:30:00Z"); + await tickAt("2026-06-15T09:45:00Z"); + + expect(dispatchedPhases()).toEqual(["ORANGE"]); + }); + + it("does not let one slot's anchor silence a second slot of the same medication", async () => { + // Two legacy single-window schedules on one medication: both carry an + // empty first-class `timesOfDay`, so a dedup key built from the dedup + // placeholder alone would collapse the evening dose into the morning one. + const med = medicationFixture(); + med.schedules.push({ + ...med.schedules[0], + id: "sched-2", + windowStart: "20:00", + windowEnd: "21:00", + }); + prismaMock.medication.findMany.mockResolvedValue([med]); + + await tickAt("2026-06-15T09:15:00Z"); // morning slot, ORANGE + await tickAt("2026-06-15T21:15:00Z"); // morning RED + evening ORANGE + + const slots = vi.mocked(dispatchNotification).mock.calls.map((call) => { + const meta = call[0].metadata as { scheduleId: string; phase: string }; + return `${meta.scheduleId}:${meta.phase}`; + }); + expect(slots).toEqual(["sched-1:ORANGE", "sched-1:RED", "sched-2:ORANGE"]); + }); +}); + +describe("medication reminder — client-managed suppression is APNs-only", () => { + it("still dispatches so the user's other channels deliver", async () => { + // `medication.clientManaged` means the iOS client owns the local + // banner. It never meant "stop telling me on Telegram / email"; the + // published contract has always said APNs. The suppression now happens + // in the dispatcher's APNs branch, so the dispatch itself must proceed. + prismaMock.medication.findMany.mockResolvedValue([ + medicationFixture({ medication: { clientManaged: true } }), + ]); + + await tickAt("2026-06-15T09:15:00Z"); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchedPhases()).toEqual(["ORANGE"]); + }); + + it("keeps the per-slot dedup for a client-managed user", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + medicationFixture({ medication: { deliveryDefault: "client" } }), + ]); + + await tickAt("2026-06-15T09:15:00Z"); + await tickAt("2026-06-15T09:30:00Z"); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 5680f6e03..ff64ab72d 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -25,7 +25,11 @@ import { getPhaseMessage, getPhaseKeyboard, } from "@/lib/jobs/reminder-phases"; -import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { + hasReminderDedupAnchor, + medicationReminderDedupKey, + writeReminderDedupAnchor, +} from "@/lib/notifications/reminder-dedup"; import { getUserTodayBounds as getUserTodayBoundsUtil, localHmAsUtc, @@ -167,12 +171,6 @@ export async function handleReminderCheck(jobs: Job[]) { // Used to localise the reminder title / message / keyboard // labels per user. Null falls back to the app default. locale: true, - // v1.4.49 M-DOUBLE-REMINDER — read the per-user prefs - // blob so the dispatch step can skip APNs sends for users - // whose iOS client has opted in to local SpeziScheduler - // reminders. Null = legacy default (clientManaged: false), - // i.e. the server reminder fires as before. - notificationPrefs: true, }, }, }, @@ -380,23 +378,29 @@ export async function handleReminderCheck(jobs: Job[]) { continue; } - // Check if this phase was already notified today for this - // time-of-day. - const existingMessage = - await prisma.telegramReminderMessage.findUnique({ - where: { - medicationId_scheduleId_date_phase_timeOfDay: { - medicationId: med.id, - scheduleId: schedule.id, - date: localDateStr, - phase: currentPhase, - timeOfDay: dedupTimeOfDay, - }, - }, - }); + // Already notified for this slot, phase and local day? The + // anchor lives in `push_attempts` and is stamped for every + // dispatched slot regardless of which channels delivered. It + // used to be the Telegram message ledger, which is written only + // when a Telegram send succeeds — so a user on email / APNs / + // ntfy had no row and the tick re-sent the same overdue notice + // every 15 minutes for the rest of the day. + const dedupReason = medicationReminderDedupKey({ + medicationId: med.id, + scheduleId: schedule.id, + slotTime, + phase: currentPhase, + localDate: localDateStr, + }); - if (existingMessage) { - // Already sent for this phase + time-of-day — skip + if ( + await hasReminderDedupAnchor(prisma, { + userId: med.user.id, + eventType: "MEDICATION_REMINDER", + reason: dedupReason, + now, + }) + ) { continue; } @@ -449,28 +453,14 @@ export async function handleReminderCheck(jobs: Job[]) { } } - // Send notification if enabled + // Send notification if enabled. + // + // `medication.clientManaged` is NOT consulted here. It suppresses + // the server's APNs send only — the iOS client owns the local + // banner, every other channel the user configured still delivers + // — so the gate lives in the dispatcher's APNs branch, which is + // the only place that knows whether an APNs send was on the table. if (med.notificationsEnabled) { - // v1.4.49 M-DOUBLE-REMINDER — opt-in client-managed - // suppression. ONLY suppresses MEDICATION_REMINDER. - if ( - isMedicationReminderClientManaged(med.user.notificationPrefs) - ) { - const doseAtIso = slotScheduledFor.toISOString(); - evt.addMeta( - "medication_reminder_suppressed_client_managed", - `${med.name}:${slotTime}`, - ); - evt.addMeta("medication_reminder_suppressed_meta", { - user_id: med.user.id, - medication_id: med.id, - schedule_id: schedule.id, - phase: currentPhase, - dose_at: doseAtIso, - }); - continue; - } - const { title, message } = getPhaseMessage( currentPhase, med.name, @@ -495,6 +485,15 @@ export async function handleReminderCheck(jobs: Job[]) { `${currentPhase}:${med.name}:${slotTime}`, ); + // Stamp the dedup anchor BEFORE dispatching: a crash between + // the two costs this phase's notice, a repeat costs the user + // a notification every 15 minutes until the day rolls. + await writeReminderDedupAnchor(prisma, { + userId: med.user.id, + eventType: "MEDICATION_REMINDER", + reason: dedupReason, + }); + try { await dispatchNotification({ eventType: "MEDICATION_REMINDER", diff --git a/src/lib/notifications/reminder-dedup.ts b/src/lib/notifications/reminder-dedup.ts new file mode 100644 index 000000000..014ee3512 --- /dev/null +++ b/src/lib/notifications/reminder-dedup.ts @@ -0,0 +1,119 @@ +/** + * Reminder dedup anchor — "this slot already went out today". + * + * The medication-reminder tick runs every 15 minutes and re-derives the + * same overdue slot on every pass, so it needs a durable record of what it + * already sent. That record used to be the Telegram message ledger, which + * only exists when a Telegram send succeeded: a user on email, APNs or + * ntfy had no row, so the tick re-sent the same overdue notice on every + * pass for the rest of the local day. + * + * The anchor now lives in `push_attempts`, the ledger the daily briefing + * and the illness escalation already use for the same purpose. Two + * properties matter and both are deliberate: + * + * - **Written per dispatched slot, not per successful send.** Anchoring + * on a delivered push would re-open the flood the moment a channel + * starts failing, which is the original defect wearing a new key. + * - **Written before the dispatch.** A crash mid-dispatch burns the slot + * for that phase rather than risking a repeat; the next phase still + * escalates, and the next local day starts clean. + * + * The rows are bookkeeping, not delivery attempts, so they carry the + * `DEDUP` channel sentinel and the operator-facing notification surfaces + * filter them out. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +/** + * Channel sentinel for a bookkeeping row. Never a real delivery target — + * `/api/admin/notifications/{health,diagnostic}` exclude it so the + * per-channel health picture stays a picture of actual sends. + */ +export const REMINDER_DEDUP_CHANNEL = "DEDUP"; + +/** + * How far back the anchor lookup reads. The key already carries the local + * date, so anything older than this cannot match; the bound exists to keep + * the read on the `(user_id, created_at DESC)` index. Two days covers every + * timezone offset plus a DST shift. + */ +const ANCHOR_LOOKBACK_MS = 48 * 60 * 60 * 1000; + +/** + * Dedup key for one medication dose slot in one phase on one local day. + * + * `scheduleId` and the slot's real `HH:mm` are BOTH in the key. Dropping + * either one silently merges slots that must stay separate: two legacy + * single-window schedules on the same medication share the empty + * first-class time-of-day, and two schedules can name the same time with + * different doses. Over-suppression is the failure mode this whole change + * risks introducing, so the key stays at least as fine as the ledger it + * replaces. + */ +export function medicationReminderDedupKey(input: { + medicationId: string; + scheduleId: string; + slotTime: string; + phase: string; + localDate: string; +}): string { + return [ + "med", + input.medicationId, + input.scheduleId, + input.slotTime, + input.phase, + input.localDate, + ].join(":"); +} + +/** + * Has this exact slot+phase+day already been dispatched? Best-effort: a DB + * error biases toward "not yet", so a transient blip delays nothing — the + * cost of a rare duplicate is far below the cost of a silently dropped + * dose reminder. + */ +export async function hasReminderDedupAnchor( + prisma: PrismaClient, + input: { userId: string; eventType: string; reason: string; now: Date }, +): Promise { + try { + const row = await prisma.pushAttempt.findFirst({ + where: { + userId: input.userId, + eventType: input.eventType, + reason: input.reason, + createdAt: { gte: new Date(input.now.getTime() - ANCHOR_LOOKBACK_MS) }, + }, + select: { id: true }, + }); + return row !== null; + } catch { + return false; + } +} + +/** + * Stamp the anchor. Never throws — a failed stamp costs a duplicate + * notification at the next tick, while a thrown error would cost the + * dispatch itself. + */ +export async function writeReminderDedupAnchor( + prisma: PrismaClient, + input: { userId: string; eventType: string; reason: string }, +): Promise { + try { + await prisma.pushAttempt.create({ + data: { + userId: input.userId, + channel: REMINDER_DEDUP_CHANNEL, + eventType: input.eventType, + result: "skipped", + reason: input.reason, + }, + }); + } catch { + // Best-effort by contract; the next tick re-derives the same slot. + } +} From ff076e80b12e4050ac1a79ce0b9c8579ada722f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:49:01 +0200 Subject: [PATCH 17/39] fix(notifications): collapse APNs pushes per dose slot, not per event type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apns-collapse-id` is a replace directive: a push carrying an id supersedes the earlier undelivered push with the same id. Every medication reminder carried the literal string MEDICATION_REMINDER, so three medications due at 08:00 arrived on the phone as one notification naming one of them. The user takes that one and misses the others with nothing on screen to suggest anything was dropped. Key the collapse on the per-slot tag the Web Push sender already coalesces on, falling back to the event type when a payload carries none. Grouping stays on `threadId`, which is what grouping is for. Discreet mode keeps the generic id — no record-identifying string on the lock screen — and a tag over Apple's 64-byte ceiling falls back rather than costing the send. --- .../senders/__tests__/apns.test.ts | 72 +++++++++++++++++++ src/lib/notifications/senders/apns.ts | 26 ++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index b06eeb199..1084f838d 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -669,6 +669,78 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.priority).toBe(10); }); + it("collapses on the per-slot tag so one dose never replaces another", async () => { + // `apns-collapse-id` is a REPLACE directive. Every medication reminder + // used to carry the literal event type, so three medications due at + // 08:00 arrived as one notification naming one of them and the user + // missed the other two. The per-slot tag the Web Push sender already + // uses keys the collapse per dose; grouping stays on the thread id. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + metadata: { + medicationId: "med-1", + webPushTag: "med:med-1:2026-06-15T08:00:00.000Z", + }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.collapseId).toBe("med:med-1:2026-06-15T08:00:00.000Z"); + expect(note.threadId).toBe("MEDICATION_REMINDER"); + }); + + it("falls back to the event type when the payload carries no per-slot tag", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MOOD_REMINDER", + }); + expect(sendMock.mock.calls[0][0].collapseId).toBe("MOOD_REMINDER"); + }); + + it("ignores a per-slot tag that would exceed the APNs collapse-id limit", async () => { + // Apple caps `apns-collapse-id` at 64 bytes and rejects the push + // outright above it. A reminder that never arrives is worse than one + // that collapses too eagerly, so an oversized tag falls back. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + metadata: { webPushTag: `med:${"x".repeat(70)}` }, + }); + expect(sendMock.mock.calls[0][0].collapseId).toBe("MEDICATION_REMINDER"); + }); + + it("discreet mode keeps the generic collapse id even with a per-slot tag", async () => { + // The lock-screen-visible routing metadata must not name the event or + // the record it belongs to; discreet wins over the per-slot tag, the + // same precedence the Web Push sender applies. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "CYCLE_PERIOD_SOON", + discreet: true, + metadata: { webPushTag: "cycle:period-soon:2026-06-15" }, + }); + expect(sendMock.mock.calls[0][0].collapseId).toBe("REMINDER"); + }); + it("urgent payload sets time-sensitive + priority=10 on a non-medication event", async () => { // v1.18.4 — an explicitly urgent event (e.g. an illness red-flag // escalation) reaches APNs's strongest no-entitlement tier: diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 56e01c0b3..07fe39a75 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -139,6 +139,12 @@ const PERMANENT_APNS_REASONS = new Set([ "DeviceTokenNotForTopic", ]); +/** + * Apple's documented ceiling for the `apns-collapse-id` header. A longer + * value is rejected with `BadCollapseId`, i.e. the push is lost. + */ +const APNS_COLLAPSE_ID_MAX_BYTES = 64; + let cachedConfig: ApnsConfig | null | undefined; const providers = new Map<"sandbox" | "production", apn.Provider>(); @@ -626,6 +632,24 @@ export async function sendViaApns( // CYCLE_PERIOD_SOON never surfaces on the lock screen. const GENERIC_EVENT = "REMINDER"; const routingEvent = payload.discreet ? GENERIC_EVENT : payload.eventType; + // `apns-collapse-id` REPLACES an earlier undelivered push carrying the + // same id. Keyed on the event type it made every medication reminder + // replace the last one, so three medications due at 08:00 arrived as a + // single notification naming a single medication. Key it on the + // per-slot tag the Web Push sender already coalesces on + // (`medicationDoseTag`), and keep grouping — `threadId` — on the event. + // Discreet mode wins: no record-identifying string on the lock screen. + // Apple caps the header at 64 bytes and rejects anything longer, so an + // oversized tag falls back rather than costing the send. + const slotTag = + typeof payload.metadata?.webPushTag === "string" && + Buffer.byteLength(payload.metadata.webPushTag, "utf8") <= + APNS_COLLAPSE_ID_MAX_BYTES + ? payload.metadata.webPushTag + : undefined; + const collapseId = payload.discreet + ? routingEvent + : (slotTag ?? routingEvent); let workingEnv: "production" | "sandbox" | null = null; for (const attemptEnv of envSequence) { result = await sendApnsPush({ @@ -668,7 +692,7 @@ export async function sendViaApns( } : {}), }, - collapseId: routingEvent, + collapseId, }); if (result.ok) { From 3088753a920823f29c4b046a4ebababe675bfb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:57:54 +0200 Subject: [PATCH 18/39] docs(reminders): drop the stale client-managed cross-reference The module-gate branch cited the client-managed skip as its precedent for advancing past a cycle. That skip no longer exists in this file, and the reason the gate advances is its own: a disabled module has nothing left to deliver. --- src/lib/jobs/measurement-reminder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 963459283..0e797711c 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -309,7 +309,8 @@ export async function runMeasurementReminderTick( summary.skippedModuleDisabled += 1; // Advance past this cycle so a disabled-module reminder does not pin // the server tick re-evaluating the same overdue slot every 15 - // minutes for the rest of the day (the client-managed precedent). + // minutes for the rest of the day. The user turned the module off, + // so there is nothing this reminder could still deliver. await advanceNextDue(prisma, reminder, timezone, now); continue; } From 06935256b0e5fde065dbe5d0872c634171c8b0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:33:51 +0200 Subject: [PATCH 19/39] feat(integrations): let the ledger hold every failing leg at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `failing_leg` was one slot, so two legs of the same provider failing in overlapping windows overwrote each other. The row remembered whichever failed last, and that leg recovering then cleared an error the other one was still causing — the ledger forgot a live failure and painted the card green over it. The slot becomes a set. A failure adds its leg, a success removes its own, and the error state, the per-kind buckets and the streak anchor clear only once the set is empty. The array is ordered oldest failure first, so the last element is the leg that wrote the message `last_error` holds; when that leg recovers while others are still failing, the message is cleared rather than left describing a leg that is now healthy. An unattributed failure adds nothing and removes nothing. A single-leg provider therefore behaves exactly as before, and a multi-leg provider no longer loses what its other legs already reported. The column never reached a client — it is absent from the status serialiser and from the OpenAPI contract — so it is replaced rather than shimmed. --- docs/api/openapi.yaml | 30 ++++- messages/de.json | 16 ++- messages/en.json | 16 ++- messages/es.json | 16 ++- messages/fr.json | 16 ++- messages/it.json | 16 ++- messages/pl.json | 16 ++- .../migration.sql | 38 ++++++ prisma/schema.prisma | 42 +++++-- src/app/api/integrations/healthkit/route.ts | 19 ++- src/app/api/measurements/batch/route.ts | 34 ++++-- .../settings/integration-status-pill.tsx | 8 +- .../integrations/apple-health-card.tsx | 79 ++++++++++++- src/lib/data-wipe/wipe-plan.ts | 2 + src/lib/integrations/metric-freshness.ts | 6 + src/lib/integrations/status.ts | 108 +++++++++++++++--- src/lib/openapi/routes/integrations.ts | 18 +++ src/lib/openapi/routes/measurements.ts | 11 +- 18 files changed, 433 insertions(+), 58 deletions(-) create mode 100644 prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8c734b58e..dac00eb9e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12100,10 +12100,11 @@ components: items: $ref: "#/components/schemas/AppleHealthBatchEntry" syncTrigger: - description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake) - so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere - else — it does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: - pre-#66 clients omit it." + description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake). + Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` + for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not + affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients + omit it." type: string enum: - foreground @@ -32631,6 +32632,27 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When HealthKit last synced for this user; null when never (and always null on the PATCH echo). + lastSyncTrigger: + description: What the client declared triggered the most recent accepted batch. Null when the client sent no trigger — + an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo. + anyOf: + - type: string + enum: + - foreground + - background + - push + description: What triggered a HealthKit batch. + - type: "null" + lastBackgroundSyncAt: + description: When a batch last arrived without the app being opened (a `background` or `push` trigger). Null when that + has never happened. `lastSyncedAt` alone cannot separate a phone that delivers on its own from one that only + delivers while the app is open; this field is what does. Present on the GET read; omitted from the PATCH + echo. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" syncHealth: description: Present on the GET read; omitted from the PATCH echo. $ref: "#/components/schemas/SyncHealth" diff --git a/messages/de.json b/messages/de.json index 6cacb7616..5c3cc6864 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5297,7 +5297,6 @@ "minutesAgo": "vor {count} min", "hoursAgo": "vor {count} Std.", "daysAgo": "vor {count} d", - "ariaLabel": "Integrationsstatus", "resumeCta": "Wieder verbinden", "resumeSuccess": "Integration wieder aktiv", "resumeError": "Wieder verbinden fehlgeschlagen", @@ -5841,7 +5840,20 @@ "backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.", "importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.", "importAction": "Einmaligen Import öffnen", - "staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen." + "staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.", + "delivery": { + "title": "Übermittlung", + "lastTriggerLabel": "Letzte Synchronisierung kam", + "lastBackgroundLabel": "Letzte Hintergrund-Synchronisierung", + "backgroundNever": "Nie — bisher kam jede Synchronisierung bei geöffneter App an", + "noSyncYet": "Es ist noch keine Synchronisierung angekommen, daher gibt es hier nichts zu berichten.", + "trigger": { + "foreground": "bei geöffneter App", + "background": "im Hintergrund", + "push": "durch einen Push-Weckruf", + "unknown": "Auslöser von der App nicht gemeldet" + } + } }, "garminInfo": { "title": "Garmin", diff --git a/messages/en.json b/messages/en.json index f7e401020..f93b91fdf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5297,7 +5297,6 @@ "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", - "ariaLabel": "Integration status", "resumeCta": "Reconnect", "resumeSuccess": "Integration resumed", "resumeError": "Reconnect failed", @@ -5841,7 +5840,20 @@ "backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.", "importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.", "importAction": "Open one-shot import", - "staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions." + "staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.", + "delivery": { + "title": "Delivery", + "lastTriggerLabel": "Latest sync arrived", + "lastBackgroundLabel": "Last background sync", + "backgroundNever": "Never — every sync so far arrived with the app open", + "noSyncYet": "No sync has arrived yet, so there is nothing to report here.", + "trigger": { + "foreground": "with the app open", + "background": "in the background", + "push": "from a push wake", + "unknown": "trigger not reported by the app" + } + } }, "garminInfo": { "title": "Garmin", diff --git a/messages/es.json b/messages/es.json index a856d5850..a1248c1fe 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5297,7 +5297,6 @@ "minutesAgo": "hace {count} min", "hoursAgo": "hace {count} h", "daysAgo": "hace {count} d", - "ariaLabel": "Estado de la integración", "resumeCta": "Reconectar", "resumeSuccess": "Integración reanudada", "resumeError": "Reconexión fallida", @@ -5841,7 +5840,20 @@ "backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.", "importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.", "importAction": "Abrir importación puntual", - "staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health." + "staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.", + "delivery": { + "title": "Entrega", + "lastTriggerLabel": "La última sincronización llegó", + "lastBackgroundLabel": "Última sincronización en segundo plano", + "backgroundNever": "Nunca — hasta ahora todas las sincronizaciones llegaron con la app abierta", + "noSyncYet": "Aún no ha llegado ninguna sincronización, así que no hay nada que informar aquí.", + "trigger": { + "foreground": "con la app abierta", + "background": "en segundo plano", + "push": "desde un aviso push", + "unknown": "la app no informó del desencadenante" + } + } }, "garminInfo": { "title": "Garmin", diff --git a/messages/fr.json b/messages/fr.json index 052b02a26..79d87c87a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5297,7 +5297,6 @@ "minutesAgo": "il y a {count} min", "hoursAgo": "il y a {count} h", "daysAgo": "il y a {count} j", - "ariaLabel": "Statut de l’intégration", "resumeCta": "Reconnecter", "resumeSuccess": "Intégration reprise", "resumeError": "Échec de la reconnexion", @@ -5841,7 +5840,20 @@ "backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.", "importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.", "importAction": "Ouvrir l’import ponctuel", - "staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health." + "staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.", + "delivery": { + "title": "Livraison", + "lastTriggerLabel": "La dernière synchronisation est arrivée", + "lastBackgroundLabel": "Dernière synchronisation en arrière-plan", + "backgroundNever": "Jamais — jusqu'ici chaque synchronisation est arrivée avec l'app ouverte", + "noSyncYet": "Aucune synchronisation n'est encore arrivée, il n'y a donc rien à signaler ici.", + "trigger": { + "foreground": "avec l'app ouverte", + "background": "en arrière-plan", + "push": "depuis un réveil push", + "unknown": "déclencheur non signalé par l'app" + } + } }, "garminInfo": { "title": "Garmin", diff --git a/messages/it.json b/messages/it.json index deda44729..a192e831f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5297,7 +5297,6 @@ "minutesAgo": "{count} min fa", "hoursAgo": "{count} h fa", "daysAgo": "{count} g fa", - "ariaLabel": "Stato dell’integrazione", "resumeCta": "Riconnetti", "resumeSuccess": "Integrazione ripresa", "resumeError": "Riconnessione fallita", @@ -5841,7 +5840,20 @@ "backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.", "importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.", "importAction": "Apri importazione una tantum", - "staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health." + "staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.", + "delivery": { + "title": "Consegna", + "lastTriggerLabel": "L'ultima sincronizzazione è arrivata", + "lastBackgroundLabel": "Ultima sincronizzazione in background", + "backgroundNever": "Mai — finora ogni sincronizzazione è arrivata con l'app aperta", + "noSyncYet": "Non è ancora arrivata alcuna sincronizzazione, quindi qui non c'è nulla da riferire.", + "trigger": { + "foreground": "con l'app aperta", + "background": "in background", + "push": "da una sveglia push", + "unknown": "trigger non segnalato dall'app" + } + } }, "garminInfo": { "title": "Garmin", diff --git a/messages/pl.json b/messages/pl.json index cb49beb28..c7281731c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5297,7 +5297,6 @@ "minutesAgo": "{count} min temu", "hoursAgo": "{count} godz. temu", "daysAgo": "{count} dni temu", - "ariaLabel": "Status integracji", "resumeCta": "Połącz ponownie", "resumeSuccess": "Integracja wznowiona", "resumeError": "Ponowne połączenie nieudane", @@ -5841,7 +5840,20 @@ "backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.", "importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.", "importAction": "Otwórz jednorazowy import", - "staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health." + "staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.", + "delivery": { + "title": "Dostarczanie", + "lastTriggerLabel": "Ostatnia synchronizacja dotarła", + "lastBackgroundLabel": "Ostatnia synchronizacja w tle", + "backgroundNever": "Nigdy — jak dotąd każda synchronizacja dotarła przy otwartej aplikacji", + "noSyncYet": "Żadna synchronizacja jeszcze nie dotarła, więc nie ma tu nic do zgłoszenia.", + "trigger": { + "foreground": "przy otwartej aplikacji", + "background": "w tle", + "push": "z wybudzenia push", + "unknown": "aplikacja nie zgłosiła wyzwalacza" + } + } }, "garminInfo": { "title": "Garmin", diff --git a/prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql b/prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql new file mode 100644 index 000000000..c49c4a46c --- /dev/null +++ b/prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql @@ -0,0 +1,38 @@ +-- The integration ledger stops erasing itself, and the HealthKit trigger gets +-- somewhere to live. +-- +-- 1. `failing_leg` → `failing_legs` +-- +-- `failing_leg` was a single last-writer-wins slot. Withings runs four legs on +-- four crons and WHOOP four resources on four more, so two legs failing in +-- overlapping windows overwrote each other: the row remembered whichever failed +-- last, and a success from that leg then cleared an error the other one was +-- still causing. The slot becomes a set. A failure adds its leg, a success +-- removes its own, and the row's error state, per-kind buckets and streak +-- anchor clear only when the set is empty. +-- +-- The array is ordered oldest-failure-first, so the last element is the leg that +-- owns whatever message `last_error` holds. +-- +-- 2. `users`: the HealthKit sync trigger, persisted +-- +-- The batch route has accepted an optional `syncTrigger` since v1.32.8 and put +-- it on the wide event, where no human reads it. Persisting it — plus the +-- instant of the most recent background-triggered batch — is what lets the +-- Apple Health card answer whether the phone is delivering on its own or only +-- when the app is open. Both nullable and additive. + +ALTER TABLE "integration_statuses" ADD COLUMN "failing_legs" JSONB; + +-- Carry every attributed leg forward as a one-element set. Rows whose +-- `failing_leg` is NULL become NULL too, not an empty array: NULL and `[]` both +-- read as "failing, but unattributed", and leaving them NULL keeps the backfill +-- to the rows that actually carry information. +UPDATE "integration_statuses" +SET "failing_legs" = jsonb_build_array("failing_leg") +WHERE "failing_leg" IS NOT NULL; + +ALTER TABLE "integration_statuses" DROP COLUMN "failing_leg"; + +ALTER TABLE "users" ADD COLUMN "healthkit_last_sync_trigger" TEXT; +ALTER TABLE "users" ADD COLUMN "healthkit_last_background_sync_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 367884b30..60e1fecf7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -319,9 +319,9 @@ model User { // must never be the OpenAI key. Separate columns make that structural // instead of a comment. The base URL passes the same SSRF floor as the // Local provider (`isPublicUrl` unless the operator allowlisted the host). - aiCompatBaseUrl String? @map("ai_compat_base_url") - aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted") - aiCompatModel String? @map("ai_compat_model") + aiCompatBaseUrl String? @map("ai_compat_base_url") + aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted") + aiCompatModel String? @map("ai_compat_model") // v1.4.16 phase B5b — ordered fallback chain for multi-provider AI // redundancy. Each entry is `{ providerType, priority, enabled }`, @@ -415,8 +415,20 @@ model User { // HealthKit (iOS) integration config — list of per-metric direction toggles. // Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt } - healthKitConfigJson Json? @map("healthkit_config_json") - healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at") + healthKitConfigJson Json? @map("healthkit_config_json") + healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at") + // What triggered the most recent accepted HealthKit batch — "foreground", + // "background" or "push", exactly as the client declared it on the write. + // Null when the client sent no trigger (an older build, or a batch that + // predates the field). + // + // `healthKitLastBackgroundSyncAt` dates the most recent batch that arrived + // WITHOUT the app being opened. It is the one instrument that tells "the + // phone is delivering on its own" apart from "every arrival happened while + // the app was on screen", which is otherwise indistinguishable from the + // outside — `healthKitLastSyncedAt` alone looks identical either way. + healthKitLastSyncTrigger String? @map("healthkit_last_sync_trigger") + healthKitLastBackgroundSyncAt DateTime? @map("healthkit_last_background_sync_at") // Display name (optional override of username for greetings). displayName String? @map("display_name") @@ -4175,11 +4187,21 @@ model IntegrationStatus { // blip apart from a dead pipe. Cleared by a success that is entitled to // clear it, by a reconnect, a disconnect, and a resume. failingSinceAt DateTime? @map("failing_since_at") - // Which sync leg owns the error currently on the row. `null` = the provider - // has a single leg, or the failure predates leg attribution. A success from - // a DIFFERENT leg may not clear an error this one did not cause — see - // `recordSyncSuccess`. - failingLeg String? @map("failing_leg") + // Every sync leg currently known to be failing, oldest failure first, as a + // JSON string array. A single slot could only ever remember one leg, so two + // legs of the same provider failing in overlapping windows erased each other + // and the row forgot one of them. A failure adds its leg; a success removes + // its own; the error state, the buckets and `failingSinceAt` clear only when + // the array is empty — see `recordSyncSuccess`. + // + // The LAST element is the leg that failed most recently and therefore owns + // whatever message `lastError` holds. When that leg recovers while others are + // still failing, `lastError` is cleared rather than left describing a leg that + // is now healthy. + // + // An empty array means "failing, but unattributed" (a single-leg provider, or + // a failure recorded without a leg), under which any success clears the row. + failingLegs Json? @map("failing_legs") alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index b2b354faa..8509b12ba 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -130,12 +130,27 @@ export const GET = apiHandler(async () => { const row = await prisma.user.findUnique({ where: { id: user.id }, - select: { healthKitConfigJson: true, healthKitLastSyncedAt: true }, + select: { + healthKitConfigJson: true, + healthKitLastSyncedAt: true, + healthKitLastSyncTrigger: true, + healthKitLastBackgroundSyncAt: true, + }, }); const stored = parseStored(row?.healthKitConfigJson ?? null); const entries = mergeWithDefaults(stored); const lastSyncedAt = row?.healthKitLastSyncedAt?.toISOString() ?? null; + // What triggered the most recent accepted batch, and when data last arrived + // without the app being opened. `lastSyncedAt` alone cannot separate those + // two cases, which is the whole diagnostic: a card that only ever says "last + // sync 10 minutes ago" reads the same whether the phone is delivering by + // itself or whether every arrival was the user opening the app. Null in + // either field is honest absence — an older client that declares no trigger, + // or a pipe that has never delivered in the background. + const lastSyncTrigger = row?.healthKitLastSyncTrigger ?? null; + const lastBackgroundSyncAt = + row?.healthKitLastBackgroundSyncAt?.toISOString() ?? null; // Apple Health is push-based: the iOS app delivers and nothing here polls, so // liveness is a data question, not an attempt question. Without a threshold a @@ -157,6 +172,8 @@ export const GET = apiHandler(async () => { return apiSuccess({ entries, lastSyncedAt, + lastSyncTrigger, + lastBackgroundSyncAt, syncHealth, metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), }); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 77bd7252f..68cee2ff2 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -169,12 +169,14 @@ const batchEntrySchema = z.object({ }); // v1.32.8 (iOS #66) — optional per-REQUEST diagnostic tag naming what woke the -// client for this sync. Purely observational: it flows into the -// `measurement.batch.ingest` wide event and NOTHING else — never persisted, -// never part of the dedup key, never an input to attribution or how a sample is -// stored. Only the client knows its own wake reason, so it is client-asserted -// by necessity; being diagnostic-only, that carries no trust surface. Optional -// so every pre-#66 caller stays byte-for-byte unchanged. +// client for this sync. Purely diagnostic: it flows into the +// `measurement.batch.ingest` wide event and onto the account's HealthKit +// diagnostic columns, which the Apple Health card reads. It is never part of +// the dedup key and never an input to attribution or how a sample is stored — +// sending it or omitting it produces byte-identical row outcomes. Only the +// client knows its own wake reason, so it is client-asserted by necessity; +// being diagnostic-only, that carries no trust surface. Optional so every +// pre-#66 caller stays byte-for-byte unchanged. const syncTriggerEnum = z.enum(["foreground", "background", "push"]); const batchPayloadSchema = z.object({ @@ -644,10 +646,28 @@ async function postBatch(request: NextRequest): Promise { // This batch route is the native HealthKit ingestion boundary. Stamp only // after every attempted write has reached a durable, non-failed verdict; // MANUAL-only and skipped-only batches are not HealthKit syncs. + // + // The trigger the client declared is stamped alongside, and a background- or + // push-triggered batch additionally dates `healthKitLastBackgroundSyncAt`. + // Without that second timestamp there is no way to tell a phone that delivers + // on its own from one that only ever delivers while the app is open — both + // look like a moving `healthKitLastSyncedAt`. A batch that declares no + // trigger writes null over the previous value rather than leaving a stale one + // standing: the field describes THIS sync, and an older build's silence is + // not evidence about it. if (healthKitSyncSucceeded) { + const syncedAt = new Date(); + const deliveredWithoutTheApp = + syncTrigger === "background" || syncTrigger === "push"; await prisma.user.update({ where: { id: user.id }, - data: { healthKitLastSyncedAt: new Date() }, + data: { + healthKitLastSyncedAt: syncedAt, + healthKitLastSyncTrigger: syncTrigger ?? null, + ...(deliveredWithoutTheApp + ? { healthKitLastBackgroundSyncAt: syncedAt } + : {}), + }, }); } diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx index 0850617cc..15f15182f 100644 --- a/src/components/settings/integration-status-pill.tsx +++ b/src/components/settings/integration-status-pill.tsx @@ -201,7 +201,13 @@ export function IntegrationStatusPill({ ? "destructive" : "outline" } - aria-label={t("settings.integrationPill.ariaLabel")} + // No `aria-label` here. One used to sit on the badge and, being a + // generic string, it REPLACED the accessible name with itself — a screen + // reader announced the same words for "connected · 4 minutes ago" as for + // "error", which is the one distinction the pill exists to make. The + // visible label is the accessible name; the relative time stays + // `aria-hidden` because it is decoration on top of a state that already + // reads. className={cn( "max-w-full whitespace-nowrap", state === "connected" && connectedChipClass, diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index 135d45607..5a8e6ccab 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -18,7 +18,10 @@ import { HeartPulse, Upload } from "lucide-react"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { SettingsCard } from "@/components/settings/settings-card"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; +import { + formatRelative, + IntegrationStatusPill, +} from "@/components/settings/integration-status-pill"; import { Button } from "@/components/ui/button"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; @@ -31,12 +34,84 @@ import { queryKeys } from "@/lib/query-keys"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; import { IntegrationErrorMessage, pillStateForVerdict } from "./shared"; +type SyncTrigger = "foreground" | "background" | "push"; + interface HealthKitStatus { lastSyncedAt: string | null; + /** What the client said woke it for the most recent accepted batch. */ + lastSyncTrigger?: SyncTrigger | null; + /** When data last arrived without the app being opened. */ + lastBackgroundSyncAt?: string | null; syncHealth?: SyncHealth; metricFreshness?: MetricFreshnessEntry[]; } +const TRIGGER_KEYS: Record = { + foreground: "settings.appleHealth.delivery.trigger.foreground", + background: "settings.appleHealth.delivery.trigger.background", + push: "settings.appleHealth.delivery.trigger.push", +}; + +/** + * Whether the phone is delivering on its own, or only while someone is looking + * at it. `lastSyncedAt` cannot tell those apart — a card that reports nothing + * but "last sync 10 minutes ago" reads identically in both cases, which is why + * "is background delivery working?" was unanswerable from either side. The + * trigger of the most recent batch and the date of the last background arrival + * are the two facts that settle it, and each is shown only when it exists: a + * client that reports no trigger says so rather than being guessed at. + */ +function DeliveryDiagnostic({ + status, + t, +}: { + status: HealthKitStatus; + t: (key: string, params?: Record) => string; +}) { + const trigger = status.lastSyncTrigger ?? null; + const triggerLabel = t( + trigger + ? TRIGGER_KEYS[trigger] + : "settings.appleHealth.delivery.trigger.unknown", + ); + const backgroundAt = status.lastBackgroundSyncAt; + + return ( +
+

+ {t("settings.appleHealth.delivery.title")} +

+ {status.lastSyncedAt ? ( +
+
+
+ {t("settings.appleHealth.delivery.lastTriggerLabel")} +
+
{triggerLabel}
+
+
+
+ {t("settings.appleHealth.delivery.lastBackgroundLabel")} +
+
+ {backgroundAt + ? formatRelative( + Math.max(0, Date.now() - new Date(backgroundAt).getTime()), + t, + ) + : t("settings.appleHealth.delivery.backgroundNever")} +
+
+
+ ) : ( +

+ {t("settings.appleHealth.delivery.noSyncYet")} +

+ )} +
+ ); +} + export function AppleHealthCard({ enabled }: { enabled: boolean }) { const { t } = useTranslations(); const { @@ -96,6 +171,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {

)} + {showPill && } + = { strava: "STRAVA", polar: "POLAR", + // These three write workouts too, and were missing from this table, so their + // cards could report every measurement type fresh while the workout leg had + // been silent for weeks — the freshness disclosure had no row to go quiet. + whoop: "WHOOP", + fitbit: "FITBIT", + "google-health": "GOOGLE_HEALTH", }; /** diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 8edc70e9f..8ae5da996 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -229,6 +229,32 @@ const ERROR_STATES: ReadonlySet = new Set([ "parked", ]); +/** + * Read the `failingLegs` JSON column into an ordered list of leg names. + * + * Anything that is not an array of strings — a legacy `null`, a hand-edited + * row, a shape from a future schema — reads as the empty set, which is the + * pre-existing "unattributed failure" semantics under which any success clears + * the row. Non-string members are dropped rather than coerced: a leg name is an + * identifier the caller passes, and inventing one from a number would let a + * success match a leg nobody ever recorded. + */ +function readFailingLegs(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +/** + * Append `leg` as the most recent failure, moving it to the end if it is + * already in the set. Ordering is load-bearing: the last element is the leg + * that wrote the message `lastError` currently holds, which is what lets a + * partial recovery tell "this message still describes a failing leg" from + * "this message belongs to a leg that has since recovered". + */ +function withLegRecorded(current: readonly string[], leg: string): string[] { + return [...current.filter((entry) => entry !== leg), leg]; +} + export interface IntegrationStatusSnapshot { integration: IntegrationKey; state: IntegrationState; @@ -373,15 +399,26 @@ export interface RecordSyncSuccessOptions { * the state that the sleep leg had recorded at :15. The failing leg's strike * ladder was reset several times an hour, so it could never reach the alert * threshold or the park window, and the card painted a green "connected · 12 - * minutes ago" over a data class that had stopped arriving. When the row holds - * an error owned by a DIFFERENT named leg, this success records its attempt and - * leaves the error standing. + * minutes ago" over a data class that had stopped arriving. + * + * This success removes its OWN leg from the failing set and clears the row only + * once that set is empty. The set matters because the previous single slot + * could remember one leg at a time: with sleep and activity both failing, the + * second failure overwrote the first, and the second leg recovering then wiped + * an error the first was still causing. The ledger forgot a live failure and + * went green over it — the exact case this set exists to hold. * * The partial path deliberately does NOT advance `lastSuccessAt`. That field is * what the freshness arms and the card's "connected · X ago" read, and moving * it forward while another leg is dead is exactly the sentence this change * exists to stop the ledger from saying. `lastAttemptAt` does advance, so the * row still reads as "being tried" rather than ageing into `stalled`. + * + * `lastError` is cleared on the partial path when the recovering leg is the one + * that wrote it. The message then describes a leg that is now healthy, and a + * row that keeps showing it is describing the wrong failure; the next attempt + * by a still-failing leg writes its own. No message is honest here — a guessed + * one would not be. */ export async function recordSyncSuccess( userId: string, @@ -394,18 +431,28 @@ export async function recordSyncSuccess( if (leg !== null) { const existing = await prisma.integrationStatus.findUnique({ where: { userId_integration: { userId, integration } }, - select: { state: true, failingLeg: true }, + select: { state: true, failingLegs: true }, }); + const failingLegs = readFailingLegs(existing?.failingLegs); + const remaining = failingLegs.filter((entry) => entry !== leg); const heldByAnotherLeg = existing != null && - existing.failingLeg != null && - existing.failingLeg !== leg && + remaining.length > 0 && ERROR_STATES.has(existing.state); if (heldByAnotherLeg) { + // The recovering leg owned `lastError` iff it was the most recent + // failure, i.e. the last element. Dropping it leaves the row without a + // message for any leg that is still failing, which is the honest state + // until one of them fails again and writes its own. + const ownedLastError = failingLegs[failingLegs.length - 1] === leg; await prisma.integrationStatus.update({ where: { userId_integration: { userId, integration } }, - data: { lastAttemptAt: now }, + data: { + lastAttemptAt: now, + failingLegs: remaining, + ...(ownedLastError ? { lastError: null } : {}), + }, }); // Meta only — the enclosing wide event owns its own action name (the // cron task), and overwriting it here would rename the job. @@ -414,7 +461,7 @@ export async function recordSyncSuccess( integration_partial_success: { integration, succeeded_leg: leg, - failing_leg: existing.failingLeg, + failing_legs: remaining, }, }, }); @@ -435,6 +482,7 @@ export async function recordSyncSuccess( lastSuccessAt: now, lastAttemptAt: now, consecutiveFailuresByKind: zeroBuckets(), + failingLegs: [], }, update: { state: "connected", @@ -444,7 +492,7 @@ export async function recordSyncSuccess( consecutiveFailuresByKind: zeroBuckets(), persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }, }); @@ -518,10 +566,20 @@ export async function recordSyncFailure( consecutiveFailuresByKind: true, persistentFailureStartedAt: true, failingSinceAt: true, + failingLegs: true, alertedAt: true, }, }); + // The failing set, with this leg appended as the most recent failure. An + // unattributed failure (no leg — a single-leg provider, or a recorder that + // does not know which leg it caught) adds nothing and removes nothing: it + // carries no attribution to record, and erasing what other legs already + // reported would let one of their successes clear an error it did not fix. + const existingLegs = readFailingLegs(existing?.failingLegs); + const failingLegs = + leg !== null ? withLegRecorded(existingLegs, leg) : existingLegs; + // Resolve the starting bucket envelope: // - existing JSON value if present // - zero envelope for a row that has never written a bucket payload @@ -627,7 +685,7 @@ export async function recordSyncFailure( consecutiveFailuresByKind: buckets, persistentFailureStartedAt: isPersistent ? now : null, failingSinceAt, - failingLeg: leg, + failingLegs, }, update: { state: newState, @@ -636,7 +694,7 @@ export async function recordSyncFailure( consecutiveFailuresByKind: buckets, persistentFailureStartedAt, failingSinceAt, - failingLeg: leg, + failingLegs, }, }); @@ -663,6 +721,10 @@ export async function recordSyncFailure( // "we concluded it was after a day of retrying". escalatedFrom: escalatedFromTransient ? kind : null, failingLeg: leg, + // Every leg the row currently holds an error for, not just this one — + // the audit trail is where an operator reconstructs an overlapping + // outage, and one leg per row was never enough to do it. + failingLegs, failingSinceAt: failingSinceAt.toISOString(), streakAgeMs, errorCode: errorCode ?? null, @@ -776,7 +838,7 @@ export async function resumeIntegrationFromPark( consecutiveFailuresByKind: zeroBuckets(), persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }, }); @@ -833,11 +895,23 @@ export async function parkIntegrationAtReauth(opts: { // audit log when the state or error changes. const existing = await prisma.integrationStatus.findUnique({ where: { userId_integration: { userId, integration } }, - select: { state: true, lastError: true, failingSinceAt: true }, + select: { + state: true, + lastError: true, + failingSinceAt: true, + failingLegs: true, + }, }); const isFreshPark = existing?.state !== "error_reauth" || existing.lastError !== encryptedError; + // Same set semantics as `recordSyncFailure`: a scope-skip park adds its leg + // to whatever the row already holds rather than replacing it, so a sibling + // leg's independent failure survives the park. + const existingLegs = readFailingLegs(existing?.failingLegs); + const failingLegs = + leg !== null ? withLegRecorded(existingLegs, leg) : existingLegs; + await prisma.integrationStatus.upsert({ where: { userId_integration: { userId, integration } }, create: { @@ -851,7 +925,7 @@ export async function parkIntegrationAtReauth(opts: { // 3-strike runway before paging. consecutiveFailuresByKind: zeroBuckets(), failingSinceAt: now, - failingLeg: leg, + failingLegs, }, update: { state: "error_reauth", @@ -861,7 +935,7 @@ export async function parkIntegrationAtReauth(opts: { // bucket values are preserved exactly. This is the whole point // of the helper. failingSinceAt: existing?.failingSinceAt ?? now, - failingLeg: leg, + failingLegs, }, }); @@ -948,7 +1022,7 @@ export async function markDisconnected( consecutiveFailuresByKind: zeroBuckets(), persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }, }); @@ -977,7 +1051,7 @@ export async function markReconnected( consecutiveFailuresByKind: zeroBuckets(), persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }, }); diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index df79d5520..2453dbc21 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -15,6 +15,11 @@ const healthKitDirectionEnum = z .enum(["bidirectional", "readOnly", "writeOnly", "disabled"]) .describe("Per-metric sync direction."); +// Mirror the batch route's `syncTriggerEnum` — what woke the client for a sync. +const healthKitSyncTriggerEnum = z + .enum(["foreground", "background", "push"]) + .describe("What triggered a HealthKit batch."); + // Resolved entry (defaults merged): `kind` + `enabled` are always present. const healthKitEntry = z .object({ @@ -89,6 +94,19 @@ const healthKitConfigResponse = z .describe( "When HealthKit last synced for this user; null when never (and always null on the PATCH echo).", ), + lastSyncTrigger: healthKitSyncTriggerEnum + .nullable() + .optional() + .describe( + "What the client declared triggered the most recent accepted batch. Null when the client sent no trigger — an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo.", + ), + lastBackgroundSyncAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + "When a batch last arrived without the app being opened (a `background` or `push` trigger). Null when that has never happened. `lastSyncedAt` alone cannot separate a phone that delivers on its own from one that only delivers while the app is open; this field is what does. Present on the GET read; omitted from the PATCH echo.", + ), syncHealth: syncHealth .optional() .describe("Present on the GET read; omitted from the PATCH echo."), diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 4c4660d36..d9c62a33a 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -112,15 +112,16 @@ const batchPayloadSchema = z .object({ entries: z.array(batchEntrySchema).min(1).max(500), // v1.32.8 (iOS #66) — optional per-request diagnostic tag naming what woke - // the client for this sync. Observability only: it is recorded on the - // ingest wide event and NEVER persisted, deduped on, or used in - // attribution — sending it or omitting it produces byte-identical row - // outcomes. Omit on any client that does not track its wake reason. + // the client for this sync. Diagnostic only: it is recorded on the ingest + // wide event and on the account's HealthKit diagnostic fields, which + // `GET /api/integrations/healthkit` publishes. It is never deduped on or + // used in attribution — sending it or omitting it produces byte-identical + // row outcomes. Omit on any client that does not track its wake reason. syncTrigger: z .enum(["foreground", "background", "push"]) .optional() .describe( - "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake) so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere else — it does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.", + "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.", ), }) .meta({ From 4e95df9e8d419b38c40def59060f90607ee8e21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:38:33 +0200 Subject: [PATCH 20/39] feat(integrations): tell the Apple Health card how the last sync arrived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the wide event, where no human reads it. Half of what was promised got built: the value is written and appears in three places in the codebase, none of which is a screen anyone looks at. Persist it on the account, alongside the instant of the most recent batch that arrived without the app being opened, and publish both from `GET /api/integrations/healthkit`. The card now names how the last sync arrived and when data last came in on its own. `healthKitLastSyncedAt` alone reads identically whether the phone delivers by itself or only while someone has the app open, which is why "is background delivery working" could not be settled from either side. A batch that declares no trigger writes null rather than leaving the previous one standing — the field describes this sync, and an older client's silence is not evidence about it. A foreground batch leaves the background timestamp alone; overwriting it would erase the only answer the card has. Also here: WHOOP, Fitbit and Google Health join the workout-freshness table. All three write workouts, and without an entry their cards could report every measurement type fresh while the workout leg had been silent for weeks. And the status pill drops a generic `aria-label` that replaced its own accessible name, so a screen reader announced the same words for a healthy connection as for a broken one. --- .../healthkit/__tests__/route.test.ts | 34 ++++ .../batch/__tests__/sync-trigger.test.ts | 75 +++++++- .../integration-status-pill.test.tsx | 24 +++ .../__tests__/apple-health-card.test.tsx | 68 ++++++++ .../__tests__/metric-freshness.test.ts | 30 ++++ src/lib/integrations/__tests__/status.test.ts | 164 ++++++++++++++++-- .../integration-sync-escalation.test.ts | 128 +++++++++++++- 7 files changed, 507 insertions(+), 16 deletions(-) diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 977316ea1..067113a7f 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -219,6 +219,8 @@ describe("GET /api/integrations/healthkit — sync health", () => { type Body = { lastSyncedAt: string | null; + lastSyncTrigger: string | null; + lastBackgroundSyncAt: string | null; syncHealth: { verdict: string; since: string | null }; metricFreshness: Array<{ type: string; @@ -293,6 +295,38 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect(byType.PULSE).toBe(false); }); + // #586 — the reporter's question is whether the phone delivers on its own or + // only while the app is open. `lastSyncedAt` reads identically either way, so + // it cannot answer it; these two fields are what can. + it("publishes the trigger of the last batch and the last background arrival", async () => { + const backgroundAt = ago(3 * 60 * 60 * 1000); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + healthKitLastSyncTrigger: "background", + healthKitLastBackgroundSyncAt: backgroundAt, + } as never); + + const body = await read(); + expect(body.lastSyncTrigger).toBe("background"); + expect(body.lastBackgroundSyncAt).toBe(backgroundAt.toISOString()); + }); + + it("reports honest absence when the client has never declared a trigger", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + healthKitLastSyncTrigger: null, + healthKitLastBackgroundSyncAt: null, + } as never); + + const body = await read(); + // An older client that reports nothing, and a pipe that has never + // delivered in the background, both read as null — never as a guess. + expect(body.lastSyncTrigger).toBeNull(); + expect(body.lastBackgroundSyncAt).toBeNull(); + }); + it("degrades to no per-metric data rather than failing the config read", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts index bd44f7946..9ad2c0104 100644 --- a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -3,8 +3,8 @@ * batch ingest. * * It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event - * so an operator can see, per batch, what woke the client (foreground app open, - * background refresh, or a push), and NOTHING else. These pin that contract: + * and kept on the account, where the Apple Health card reads it. These pin that + * contract: * * - a batch carrying `syncTrigger:"background"` is accepted and the value * rides the ingest annotation; @@ -12,7 +12,11 @@ * - an invalid value is a 422 (closed enum); * - the field never changes which rows are inserted / updated — the per-row * outcomes are byte-identical with and without it, so it cannot leak into - * dedup, attribution, or storage. + * dedup, attribution, or storage; + * - the trigger is stamped on the account, and a background or push trigger + * additionally dates the last background arrival. That second timestamp is + * the whole diagnostic (#586): `healthKitLastSyncedAt` alone reads the same + * whether the phone delivers on its own or only while the app is open. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -301,3 +305,68 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", () expect(withoutBody.inserted).toBe(1); }); }); + +describe("POST /api/measurements/batch — HealthKit trigger persistence (#586)", () => { + function stampedData() { + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as + { data: Record } | undefined; + return call?.data; + } + + it("dates the last background arrival when the batch says it woke in the background", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "background", + }), + ); + expect(res.status).toBe(200); + expect(stampedData()).toMatchObject({ + healthKitLastSyncedAt: expect.any(Date), + healthKitLastSyncTrigger: "background", + healthKitLastBackgroundSyncAt: expect.any(Date), + }); + }); + + it("treats a push wake as delivery without the app too", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "push", + }), + ); + expect(res.status).toBe(200); + expect(stampedData()).toMatchObject({ + healthKitLastSyncTrigger: "push", + healthKitLastBackgroundSyncAt: expect.any(Date), + }); + }); + + it("leaves the background timestamp untouched for a foreground batch", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "foreground", + }), + ); + expect(res.status).toBe(200); + const data = stampedData(); + expect(data).toMatchObject({ healthKitLastSyncTrigger: "foreground" }); + // Not written at all — a sync the user triggered by opening the app is no + // evidence about background delivery, and overwriting the older honest + // timestamp with this one would erase the only answer the card has. + expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt"); + }); + + it("writes null over a stale trigger when the client declares none", async () => { + const res = await POST( + makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }), + ); + expect(res.status).toBe(200); + const data = stampedData(); + // The field describes THIS sync. Leaving the previous batch's trigger + // standing would attribute it to a batch that never declared one. + expect(data).toMatchObject({ healthKitLastSyncTrigger: null }); + expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt"); + }); +}); diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx index 466834869..b9a2db562 100644 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -230,3 +230,27 @@ describe("IntegrationStatusPill — a failing provider states its age", () => { expect(html).toContain("Sync fehlgeschlagen"); }); }); + +describe("IntegrationStatusPill — accessible name", () => { + /** + * A generic `aria-label` used to sit on the badge. Being a label, it + * REPLACED the accessible name with itself, so a screen reader announced the + * same words for a healthy connection as for a broken one — erasing the one + * distinction the pill exists to draw. + */ + it.each([ + ["connected", "Connected"], + ["error", "Error"], + ["parked", "Paused"], + ] as const)("lets the %s label be the accessible name", (state, label) => { + const html = render( + , + ); + expect(html).not.toContain("aria-label="); + expect(html).toContain(label); + }); +}); diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index db8911958..da4083c7f 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -12,6 +12,8 @@ import pl from "../../../../../messages/pl.json"; type Payload = { lastSyncedAt: string | null; + lastSyncTrigger?: "foreground" | "background" | "push" | null; + lastBackgroundSyncAt?: string | null; syncHealth?: { verdict: string; since: string | null }; metricFreshness?: Array<{ type: string; @@ -227,3 +229,69 @@ describe("", () => { }, ); }); + +describe(" — delivery diagnostic (#586)", () => { + beforeEach(() => { + statusPayload = undefined; + statusLoading = false; + statusError = false; + }); + + it("names how the last sync arrived and when data last came in on its own", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + lastSyncTrigger: "background", + lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + }; + + const html = render(); + + expect(html).toContain('data-testid="apple-health-delivery"'); + expect(html).toContain("in the background"); + expect(html).toContain("Last background sync"); + }); + + it("says plainly that background delivery has never happened", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + lastSyncTrigger: "foreground", + lastBackgroundSyncAt: null, + syncHealth: { verdict: "fresh", since: null }, + }; + + const html = render(); + + // The reporter's question, answered rather than left to inference: every + // sync so far arrived while the app was open. + expect(html).toContain("with the app open"); + expect(html).toContain("every sync so far arrived with the app open"); + }); + + it("reports a missing trigger as unreported instead of guessing one", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + lastSyncTrigger: null, + lastBackgroundSyncAt: null, + syncHealth: { verdict: "fresh", since: null }, + }; + + const html = render(); + + expect(html).toContain( + '
trigger not reported by the app
', + ); + }); + + it("says there is nothing to report when no sync has ever arrived", () => { + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }; + + const html = render(); + + expect(html).toContain("No sync has arrived yet"); + expect(html).not.toContain("Last background sync"); + }); +}); diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts index 96de8cf4b..c5f3c0a5d 100644 --- a/src/lib/integrations/__tests__/metric-freshness.test.ts +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -130,6 +130,36 @@ describe("getSourceMetricFreshness", () => { { type: "WORKOUTS", lastSeenAt: "2026-07-19T17:00:00.000Z" }, ]); }); + + it.each(["whoop", "fitbit", "google-health"] as const)( + "carries the workout leg for %s, which writes workouts too", + async (integration) => { + const source = { + whoop: "WHOOP", + fitbit: "FITBIT", + "google-health": "GOOGLE_HEALTH", + }[integration]; + + // These three were missing from the workout table, so a card could + // report every measurement type fresh while the workout leg had been + // silent for weeks — the disclosure had no row that could go quiet. + groupByMock.mockResolvedValue([ + { + source, + type: "SLEEP_DURATION", + _max: { measuredAt: new Date("2026-07-24T04:00:00.000Z") }, + }, + ]); + workoutGroupByMock.mockResolvedValue([ + { source, _max: { startedAt: new Date("2026-06-01T09:00:00.000Z") } }, + ]); + + expect((await getSourceMetricFreshness("u1"))[integration]).toEqual([ + { type: "SLEEP_DURATION", lastSeenAt: "2026-07-24T04:00:00.000Z" }, + { type: "WORKOUTS", lastSeenAt: "2026-06-01T09:00:00.000Z" }, + ]); + }, + ); }); describe("getSourceFreshness — one source, for Apple Health", () => { diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 9be47a336..50cfb4291 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -109,7 +109,7 @@ describe("recordSyncSuccess", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); }); @@ -496,7 +496,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); }); @@ -521,7 +521,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); }); @@ -840,7 +840,7 @@ describe("resumeIntegrationFromPark", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); @@ -1032,7 +1032,7 @@ describe("recordSyncSuccess — leg scoping", () => { it("leaves a sibling leg's error standing and only records the attempt", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", - failingLeg: "sleep", + failingLegs: ["sleep"], } as never); vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( {} as never, @@ -1044,13 +1044,59 @@ describe("recordSyncSuccess — leg scoping", () => { const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; // `lastSuccessAt` deliberately absent: it is what the card's // "connected · X ago" reads, and the sleep pipe has delivered nothing. - expect(args.data).toEqual({ lastAttemptAt: expect.any(Date) }); + // `lastError` is absent too — sleep still owns the message on the row. + expect(args.data).toEqual({ + lastAttemptAt: expect.any(Date), + failingLegs: ["sleep"], + }); + }); + + it("drops only its own leg when several are failing at once", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLegs: ["sleep", "activity", "ecg"], + } as never); + vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "activity" }); + + expect(prisma.integrationStatus.upsert).not.toHaveBeenCalled(); + const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; + // The single slot could hold one of these three. Two legs are still + // failing, and both are still on the row. + expect(args.data).toMatchObject({ failingLegs: ["sleep", "ecg"] }); + // `activity` did not write the message the row holds — `ecg` did, and it + // is still failing — so the message stays. + expect(args.data).not.toHaveProperty("lastError"); + }); + + it("clears the error message when the leg that wrote it recovers first", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLegs: ["sleep", "ecg"], + } as never); + vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( + {} as never, + ); + + // `ecg` is last, so it failed most recently and owns `lastError`. + await recordSyncSuccess("u1", "withings", { leg: "ecg" }); + + const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; + // Keeping the message would leave the row describing a leg that is now + // healthy while `sleep` is the one actually failing. + expect(args.data).toMatchObject({ + failingLegs: ["sleep"], + lastError: null, + }); }); it("clears the row whole when the failing leg is the one that succeeded", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", - failingLeg: "sleep", + failingLegs: ["sleep"], } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, @@ -1064,14 +1110,14 @@ describe("recordSyncSuccess — leg scoping", () => { state: "connected", lastError: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], }); }); it("clears the row whole when the failure was never attributed to a leg", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", - failingLeg: null, + failingLegs: [], } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, @@ -1097,7 +1143,7 @@ describe("recordSyncSuccess — leg scoping", () => { it("does not treat a healthy row's stale leg marker as a held error", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "connected", - failingLeg: "sleep", + failingLegs: ["sleep"], } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, @@ -1107,4 +1153,102 @@ describe("recordSyncSuccess — leg scoping", () => { expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); }); + + it("reads a malformed leg column as unattributed rather than guessing", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + // Not an array of strings — a legacy shape, or a hand-edited row. + failingLegs: { sleep: true }, + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "ecg" }); + + // Unattributed means any success clears — the pre-existing semantics for + // a failure that named no leg, not an invented leg name. + expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); + }); +}); + +describe("recordSyncFailure — leg set", () => { + function writtenUpdate() { + return vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]! + .update as Record; + } + + it("adds a second failing leg instead of overwriting the first", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 1, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: new Date("2026-01-01T00:00:00Z"), + failingLegs: ["sleep"], + alertedAt: null, + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings activity error: 503 - upstream", + leg: "activity", + }); + + expect(writtenUpdate().failingLegs).toEqual(["sleep", "activity"]); + }); + + it("moves a re-failing leg to the end so it owns the message it just wrote", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 2, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: new Date("2026-01-01T00:00:00Z"), + failingLegs: ["sleep", "activity"], + alertedAt: null, + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings sleep error: 503 - upstream", + leg: "sleep", + }); + + // No duplicate, and `sleep` is last because it wrote `lastError`. + expect(writtenUpdate().failingLegs).toEqual(["activity", "sleep"]); + }); + + it("leaves the set alone when the failure names no leg", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 1, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: new Date("2026-01-01T00:00:00Z"), + failingLegs: ["sleep"], + alertedAt: null, + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings error: 503 - upstream", + }); + + // An unattributed failure carries nothing to record; erasing `sleep` would + // let the sleep leg's next success clear an error it did not fix. + expect(writtenUpdate().failingLegs).toEqual(["sleep"]); + }); }); diff --git a/tests/integration/integration-sync-escalation.test.ts b/tests/integration/integration-sync-escalation.test.ts index fee900389..2fc3f37f8 100644 --- a/tests/integration/integration-sync-escalation.test.ts +++ b/tests/integration/integration-sync-escalation.test.ts @@ -257,7 +257,7 @@ describe("leg-scoped success", () => { expect(current!.state).toBe("error_transient"); expect(current!.lastError).toBe(afterFailure!.lastError); expect(current!.consecutiveFailuresByKind).toMatchObject({ transient: 1 }); - expect(current!.failingLeg).toBe("sleep"); + expect(current!.failingLegs).toEqual(["sleep"]); expect(current!.failingSinceAt!.getTime()).toBe(streakStart.getTime()); // Not advanced: it is what the card's "connected · X ago" reads, and the // sleep pipe has delivered nothing. @@ -280,7 +280,7 @@ describe("leg-scoped success", () => { const current = await row("withings"); expect(current!.state).toBe("connected"); expect(current!.lastError).toBeNull(); - expect(current!.failingLeg).toBeNull(); + expect(current!.failingLegs).toEqual([]); expect(current!.failingSinceAt).toBeNull(); expect(current!.lastSuccessAt).toBeInstanceOf(Date); expect(current!.consecutiveFailuresByKind).toMatchObject({ @@ -342,7 +342,7 @@ describe("leg-scoped success", () => { const current = await row("whoop"); expect(current!.state).toBe("connected"); expect(current!.lastError).toBeNull(); - expect(current!.failingLeg).toBeNull(); + expect(current!.failingLegs).toEqual([]); expect(current!.failingSinceAt).toBeNull(); }); @@ -356,3 +356,125 @@ describe("leg-scoped success", () => { expect(current!.failingSinceAt).toBeNull(); }); }); + +describe("two legs failing in overlapping windows", () => { + /** + * The case a single `failing_leg` slot could not hold, and the one the + * v1.33.0 notes named in public rather than fixed. + * + * Sleep fails, then activity fails. With one slot, activity's failure + * overwrote sleep's, so activity recovering cleared the row whole — the + * error, the buckets and the streak anchor — while sleep was still dead. The + * card went green over a data class that had stopped arriving weeks earlier, + * and the strike ladder restarted from zero every time the healthy leg ticked. + */ + it("remembers the earlier leg after the later one recovers", async () => { + await failTransient("withings", "sleep"); + const afterSleep = await row("withings"); + const streakStart = afterSleep!.failingSinceAt!; + + await failTransient("withings", "activity"); + // Both legs are on the row. The slot could only ever have held one. + expect((await row("withings"))!.failingLegs).toEqual(["sleep", "activity"]); + + // Activity comes back. Sleep has not. + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + expect(current!.failingLegs).toEqual(["sleep"]); + // The error still stands, dated from sleep's first failure, with both + // strikes intact — this is the ladder that could never climb. + expect(current!.state).toBe("error_transient"); + expect(current!.failingSinceAt!.getTime()).toBe(streakStart.getTime()); + expect(current!.consecutiveFailuresByKind).toMatchObject({ transient: 2 }); + // Still nothing has been delivered by the sleep leg, so the field the card + // reads as "connected · X ago" must not have moved. + expect(current!.lastSuccessAt).toBeNull(); + }); + + it("drops the error message once the leg that wrote it recovers", async () => { + await failTransient("withings", "sleep"); + await recordSyncFailure({ + userId: TEST_USER_ID, + integration: "withings", + kind: "transient", + message: "activity write failed: upstream 503", + leg: "activity", + }); + // Activity failed last, so its message is the one the row holds. + expect((await row("withings"))!.lastError).not.toBeNull(); + + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + // Keeping activity's message would leave the row describing a leg that has + // recovered while sleep is the one still failing. No message is honest + // here; a guessed one would not be. + expect(current!.lastError).toBeNull(); + expect(current!.failingLegs).toEqual(["sleep"]); + expect(current!.state).toBe("error_transient"); + }); + + it("keeps the message when a leg that did not write it recovers", async () => { + await recordSyncFailure({ + userId: TEST_USER_ID, + integration: "withings", + kind: "transient", + message: "activity write failed: upstream 503", + leg: "activity", + }); + await failTransient("withings", "sleep"); + const sleepMessage = (await row("withings"))!.lastError; + + // Activity recovers, but sleep failed last and owns the message. + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + expect(current!.lastError).toBe(sleepMessage); + expect(current!.failingLegs).toEqual(["sleep"]); + }); + + it("clears the row only once every failing leg has come back", async () => { + await failTransient("withings", "sleep"); + await failTransient("withings", "activity"); + await failTransient("withings", "ecg"); + + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "ecg" }); + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "sleep" }); + expect((await row("withings"))!.state).toBe("error_transient"); + expect((await row("withings"))!.failingLegs).toEqual(["activity"]); + + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + expect(current!.state).toBe("connected"); + expect(current!.failingLegs).toEqual([]); + expect(current!.failingSinceAt).toBeNull(); + expect(current!.lastError).toBeNull(); + expect(current!.lastSuccessAt).toBeInstanceOf(Date); + expect(current!.consecutiveFailuresByKind).toMatchObject({ + transient: 0, + reauth_required: 0, + persistent: 0, + }); + }); + + it("does not duplicate a leg that keeps failing", async () => { + await failTransient("withings", "sleep"); + await failTransient("withings", "activity"); + await failTransient("withings", "sleep"); + + // Sleep moves to the end because it wrote the message the row now holds. + expect((await row("withings"))!.failingLegs).toEqual(["activity", "sleep"]); + }); + + it("does not let an unattributed failure erase a named leg", async () => { + await failTransient("withings", "sleep"); + // A recorder that caught the error at a boundary and cannot say which leg + // it belongs to. Overwriting the set here would let the sleep leg's next + // success clear an error it did not fix. + await failTransient("withings"); + + expect((await row("withings"))!.failingLegs).toEqual(["sleep"]); + }); +}); From 7e4b9d9be9e1d12f796b4440dd0a5b04e5c94c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:41:57 +0200 Subject: [PATCH 21/39] test(integrations): cover the leg-set semantics a single slot could not hold --- src/lib/integrations/__tests__/status.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 50cfb4291..8699b3f19 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -1154,6 +1154,23 @@ describe("recordSyncSuccess — leg scoping", () => { expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); }); + it("drops a non-string member rather than coercing it into a leg name", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLegs: ["sleep", 42, null], + } as never); + vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "ecg" }); + + const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; + // Coercing `42` into `"42"` would invent a leg no caller can ever succeed + // for, holding the row in error against a failure nobody recorded. + expect(args.data).toMatchObject({ failingLegs: ["sleep"] }); + }); + it("reads a malformed leg column as unattributed rather than guessing", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", From d222aa2354c249df173b288fa8990dfcc75f04d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:52:17 +0200 Subject: [PATCH 22/39] test(measurements): pin the null-trigger stamp on a batch that declares none --- .../batch/__tests__/pr-detection-hook.test.ts | 7 ++++++- .../settings/integrations/apple-health-card.tsx | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts index 200546d61..ab6f7dcf3 100644 --- a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts +++ b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts @@ -153,7 +153,12 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", expect(prisma.user.update).toHaveBeenCalledTimes(1); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { healthKitLastSyncedAt: expect.any(Date) }, + // This batch declares no trigger, so the trigger field is nulled and the + // background timestamp is not written — see the sync-trigger suite. + data: { + healthKitLastSyncedAt: expect.any(Date), + healthKitLastSyncTrigger: null, + }, }); }); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index 5a8e6ccab..858573373 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -64,10 +64,14 @@ const TRIGGER_KEYS: Record = { function DeliveryDiagnostic({ status, t, + now, }: { status: HealthKitStatus; t: (key: string, params?: Record) => string; + /** Injectable reference instant, mirroring the pill's own prop. */ + now?: Date; }) { + const reference = now ?? new Date(); const trigger = status.lastSyncTrigger ?? null; const triggerLabel = t( trigger @@ -96,7 +100,10 @@ function DeliveryDiagnostic({
{backgroundAt ? formatRelative( - Math.max(0, Date.now() - new Date(backgroundAt).getTime()), + Math.max( + 0, + reference.getTime() - new Date(backgroundAt).getTime(), + ), t, ) : t("settings.appleHealth.delivery.backgroundNever")} From eba9f15539a42df0633193b007ce9da4b37bd541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 14:19:29 +0200 Subject: [PATCH 23/39] test(integrations): prove the leg backfill against rows that carry the old value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migration container starts empty, so applying 0281 there proves the statements parse and nothing else — the backfill's WHERE matches no rows and a broken expression passes green. Every self-hoster runs it against rows that do carry a leg, and those are the only rows it exists for. Seed the pre-migration shape, run the migration's own UPDATE read out of the committed file, and assert an attributed row comes out still knowing which leg broke, an unattributed one is left alone, and a second run does not nest the array. --- .../integration-failing-legs-backfill.test.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/integration/integration-failing-legs-backfill.test.ts diff --git a/tests/integration/integration-failing-legs-backfill.test.ts b/tests/integration/integration-failing-legs-backfill.test.ts new file mode 100644 index 000000000..d50cb01fc --- /dev/null +++ b/tests/integration/integration-failing-legs-backfill.test.ts @@ -0,0 +1,133 @@ +/** + * Migration 0281's backfill, against rows that actually carry the old value. + * + * A migration test container starts EMPTY, so `pnpm db:migrate:deploy` proves + * only that the statements parse and apply — the backfill's `WHERE failing_leg + * IS NOT NULL` matches nothing and a broken expression would sail through + * green. Every existing self-hoster runs it against rows that DO carry a leg, + * and those are the only rows it exists for: a connection that has been failing + * for weeks must come out of the migration still remembering which leg broke, + * or the deploy silently forgives every live failure on the instance. + * + * The statement is read out of the migration file rather than restated here, so + * this cannot drift into testing a copy that no longer matches what ships. + * + * On the raw-SQL convention: every value-bearing statement below binds through + * positional `$N` placeholders. The one spliced string is the migration's own + * `UPDATE`, read from a committed file in this repository — a file literal, not + * an input, and the exact text the deploy runs. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, afterEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION_SQL = readFileSync( + join( + process.cwd(), + "prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql", + ), + "utf8", +); + +/** + * The one `UPDATE` in the migration, lifted verbatim. Anchoring on the + * statement keyword rather than a line number keeps the extraction stable + * against comment edits above it. + */ +function backfillStatement(): string { + const start = MIGRATION_SQL.indexOf('UPDATE "integration_statuses"'); + expect(start).toBeGreaterThan(-1); + const end = MIGRATION_SQL.indexOf(";", start); + expect(end).toBeGreaterThan(start); + return MIGRATION_SQL.slice(start, end + 1); +} + +const USER_ID = "user-failing-legs-backfill"; + +async function seedUser() { + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "backfill-subject", + passwordHash: "x", + }, + }); +} + +describe("migration 0281 — failing_leg backfill", () => { + beforeEach(async () => { + const client = getPrismaClient(); + await truncateAllTables(client); + // Re-create the pre-migration column so a row can be seeded in the shape + // the migration will actually meet on a live instance. + await client.$executeRawUnsafe( + 'ALTER TABLE "integration_statuses" ADD COLUMN "failing_leg" TEXT', + ); + await seedUser(); + }); + + afterEach(async () => { + await getPrismaClient().$executeRawUnsafe( + 'ALTER TABLE "integration_statuses" DROP COLUMN IF EXISTS "failing_leg"', + ); + }); + + async function seedRow(integration: string, failingLeg: string | null) { + await getPrismaClient().$executeRawUnsafe( + `INSERT INTO "integration_statuses" + (id, user_id, integration, state, failing_leg, failing_legs, created_at, updated_at) + VALUES ($1, $2, $3, 'error_transient', $4, NULL, NOW(), NOW())`, + `st-${integration}`, + USER_ID, + integration, + failingLeg, + ); + } + + async function readLegs(integration: string) { + const rows = await getPrismaClient().$queryRawUnsafe< + Array<{ failing_legs: unknown }> + >( + 'SELECT failing_legs FROM "integration_statuses" WHERE integration = $1', + integration, + ); + return rows[0]?.failing_legs; + } + + it("carries an attributed leg forward as a one-element set", async () => { + await seedRow("withings", "sleep"); + + await getPrismaClient().$executeRawUnsafe(backfillStatement()); + + // The whole point of the migration: an instance that has been failing on + // its sleep leg for weeks still knows that after the deploy. Losing it + // would let the next healthy leg clear the error on the first cron tick. + expect(await readLegs("withings")).toEqual(["sleep"]); + }); + + it("leaves an unattributed row alone", async () => { + await seedRow("oura", null); + + await getPrismaClient().$executeRawUnsafe(backfillStatement()); + + // NULL and `[]` both read as "failing, but unattributed" — the code's + // pre-existing semantics, under which any success clears. Writing `[]` + // here would be churn that changes nothing. + expect(await readLegs("oura")).toBeNull(); + }); + + it("is safe to run twice", async () => { + await seedRow("polar", "vitals"); + + const statement = backfillStatement(); + await getPrismaClient().$executeRawUnsafe(statement); + await getPrismaClient().$executeRawUnsafe(statement); + + // A re-run must not nest the array into `[["vitals"]]`. Migrations get + // re-applied by hand more often than anyone plans for. + expect(await readLegs("polar")).toEqual(["vitals"]); + }); +}); From 36f525c465d5302b02e1b31f9046e6d9819463e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:35:47 +0200 Subject: [PATCH 24/39] refactor(profile): route every binary-sex narrow through the one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `binaryReferenceSex` was already the declared owner of the shrink from the three stored values to the two a sex-split reference table has rows for, but eight sites still narrowed inline — including a byte-for-byte private copy in the effective-range resolver and a ternary 37 lines above a `toProfileSex` call in the same file. Each of those is a place the fallback can drift. Delete the copy, replace the ternaries, and name the pair once as `BinaryReferenceSex` so a signature that needs it stops re-listing the two literals. No behaviour changes: every site already resolved OTHER and "no answer" to the same neutral or absent value, and the existing suites pass untouched. --- src/app/api/nutrients/daily/route.ts | 4 ++-- .../insights/nutrients/micronutrients-card.tsx | 4 ++-- src/lib/ai/insight-interpretation.ts | 13 ++++++++----- src/lib/analytics/effective-range.ts | 8 ++------ src/lib/dashboard/snapshot.ts | 9 ++++++--- src/lib/insights/metric-status.ts | 4 ++-- src/lib/mcp/nutrients-read.ts | 7 +++---- src/lib/nutrients/catalog.ts | 4 +++- src/lib/profile/sex.ts | 9 ++++++++- 9 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/app/api/nutrients/daily/route.ts b/src/app/api/nutrients/daily/route.ts index dd0bfd4c6..382200b4b 100644 --- a/src/app/api/nutrients/daily/route.ts +++ b/src/app/api/nutrients/daily/route.ts @@ -22,6 +22,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { binaryReferenceSex } from "@/lib/profile/sex"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { NUTRIENT_CATALOG, @@ -72,8 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => { daySeries.push({ day: key, amount: sumByDay.get(key) ?? 0 }); } - const sex = - user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; + const sex = binaryReferenceSex(user.gender); const reference = resolveNutrientReference(nutrient, sex); annotate({ diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx index 7822d1811..998b15c91 100644 --- a/src/components/insights/nutrients/micronutrients-card.tsx +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; +import { binaryReferenceSex } from "@/lib/profile/sex"; import { NUTRIENT_CODES, isNutrientCode, @@ -51,8 +52,7 @@ export function MicronutrientsCard() { const { t } = useTranslations(); const fmt = useFormatters(); const { user } = useAuth(); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + const sex = binaryReferenceSex(user?.gender); // A failed read left `data` undefined, `rows` empty, and the card rendered // its "no micronutrient data" EmptyState — the same output as an account that diff --git a/src/lib/ai/insight-interpretation.ts b/src/lib/ai/insight-interpretation.ts index 71ad3909e..1dbf420b0 100644 --- a/src/lib/ai/insight-interpretation.ts +++ b/src/lib/ai/insight-interpretation.ts @@ -27,6 +27,8 @@ * The band position is computed HERE, server-side — never left to the model. */ +import { binaryReferenceSex, type BinaryReferenceSex } from "@/lib/profile/sex"; + /** How a band reads against health: the four-way valence the prose leans on. */ export type BandValence = "favourable" | "neutral" | "caution" | "unfavourable"; @@ -54,7 +56,7 @@ export interface MetricInterpretation { /** Ascending bands when the metric is not sex-split. */ bands?: InterpretationBand[]; /** Sex-split bands; used only when the profile carries the sex. */ - sexBands?: Record<"MALE" | "FEMALE", InterpretationBand[]>; + sexBands?: Record; /** * Optional honesty caveat surfaced in the prompt block — used where the KB * hedges the edges (consumer rating scales, proxy measurements). @@ -300,7 +302,7 @@ const INTERPRETATION_REGISTRY: Record = { */ export function resolveInterpretation( metricKey: string, - sex: "MALE" | "FEMALE" | null | undefined, + sex: BinaryReferenceSex | null | undefined, ): { unit: string; directionOfGood: DirectionOfGood; @@ -312,8 +314,9 @@ export function resolveInterpretation( if (!entry) return null; let bands: InterpretationBand[] | undefined = entry.bands; if (entry.sexBands) { - if (sex !== "MALE" && sex !== "FEMALE") return null; - bands = entry.sexBands[sex]; + const referenceSex = binaryReferenceSex(sex); + if (!referenceSex) return null; + bands = entry.sexBands[referenceSex]; } if (!bands || bands.length === 0) return null; return { @@ -398,7 +401,7 @@ export function classifyBandPosition( */ export function interpretationBandEdges( metricKey: string, - sex?: "MALE" | "FEMALE" | null, + sex?: BinaryReferenceSex | null, ): number[] { const resolved = resolveInterpretation(metricKey, sex ?? null); if (!resolved) { diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 2b78bcf06..f7be20816 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -15,6 +15,7 @@ * consistently. */ +import { binaryReferenceSex } from "@/lib/profile/sex"; import { getBpTargets } from "./bp-targets"; import { getAgeFromDateOfBirth, @@ -120,11 +121,6 @@ const GLUCOSE_DEFAULTS: Record< BEDTIME: { min: 90, max: 150 }, }; -function normalizedGender(value: string | null): "MALE" | "FEMALE" | null { - if (value === "MALE" || value === "FEMALE") return value; - return null; -} - function toDate(value: Date | string | null): Date | null { if (!value) return null; return value instanceof Date ? value : new Date(value); @@ -135,7 +131,7 @@ function defaultRange( profile: UserProfileForRange, ): TrafficRange | null { const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null); - const gender = normalizedGender(profile.gender ?? null); + const gender = binaryReferenceSex(profile.gender); const dateOfBirth = toDate(profile.dateOfBirth); switch (metric) { diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 159ba501b..1dfd43431 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -101,7 +101,11 @@ import { import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; -import { toProfileSex, type ProfileSex } from "@/lib/profile/sex"; +import { + binaryReferenceSex, + toProfileSex, + type ProfileSex, +} from "@/lib/profile/sex"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; @@ -1246,8 +1250,7 @@ export async function buildDashboardSnapshot( // never sinks the snapshot. The narrative block rides the warm phase's // healthScore; memory rides the briefing card. The locale defaults to English. const locale = options.locale ?? "en"; - const narrativeSex: "MALE" | "FEMALE" | null = - user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; + const narrativeSex = binaryReferenceSex(user.gender); const narrativeProfile = { ageYears: getAgeFromDateOfBirth(user.dateOfBirth), sex: narrativeSex, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 393563889..e1e085585 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -83,6 +83,7 @@ import { import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; +import { binaryReferenceSex } from "@/lib/profile/sex"; export interface MetricStatusResult { hasProvider: boolean; @@ -335,8 +336,7 @@ export async function generateMetricStatus(args: { : null; const ageYears = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + const sex = binaryReferenceSex(user?.gender); // v1.10.0 F1 — sharpen the coarse registry `normalRange` with the // age/sex reference-range enabler when one is available for this diff --git a/src/lib/mcp/nutrients-read.ts b/src/lib/mcp/nutrients-read.ts index 4c84f4010..6d4b457ac 100644 --- a/src/lib/mcp/nutrients-read.ts +++ b/src/lib/mcp/nutrients-read.ts @@ -20,6 +20,7 @@ */ import { prisma } from "@/lib/db"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { binaryReferenceSex, type BinaryReferenceSex } from "@/lib/profile/sex"; import { NUTRIENT_CATALOG, NUTRIENT_CODES, @@ -195,7 +196,7 @@ async function readOverview( async function readDaily( userId: string, userTz: string, - sex: "MALE" | "FEMALE" | null, + sex: BinaryReferenceSex | null, nutrient: NutrientCode, days: number, ): Promise { @@ -268,9 +269,7 @@ export async function getNutrients( select: { timezone: true, gender: true }, }); const userTz = user?.timezone || DEFAULT_TIMEZONE; - const genderValue = user?.gender ?? null; - const sex = - genderValue === "MALE" || genderValue === "FEMALE" ? genderValue : null; + const sex = binaryReferenceSex(user?.gender); if (!args.nutrient) { const days = clampDays(args.days, OVERVIEW_DEFAULT_DAYS, OVERVIEW_MAX_DAYS); diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index dedf9dc0d..d69956529 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -36,6 +36,8 @@ * its source string. */ +import type { BinaryReferenceSex } from "@/lib/profile/sex"; + export const NUTRIENT_CODES = [ // 13 vitamins "vitamin_a", @@ -331,7 +333,7 @@ export interface ResolvedNutrientReference { */ export function resolveNutrientReference( code: NutrientCode, - sex: "MALE" | "FEMALE" | null, + sex: BinaryReferenceSex | null, ): ResolvedNutrientReference | null { const ref = NUTRIENT_CATALOG[code].reference; const value = diff --git a/src/lib/profile/sex.ts b/src/lib/profile/sex.ts index c9c9e96b0..b9b95ab3b 100644 --- a/src/lib/profile/sex.ts +++ b/src/lib/profile/sex.ts @@ -20,6 +20,13 @@ /** Profile sex exactly as stored; `null` = the account gave no answer. */ export type ProfileSex = "MALE" | "FEMALE" | "OTHER" | null; +/** + * The two values a sex-split reference table actually has rows for. Named so + * a table's signature can say what it needs without re-listing the pair, and + * so the list has exactly one place to change. + */ +export type BinaryReferenceSex = "MALE" | "FEMALE"; + /** * Narrow a stored sex to the two values a sex-split reference table has * rows for. `OTHER` and "no answer" both resolve to `null`, and every @@ -29,7 +36,7 @@ export type ProfileSex = "MALE" | "FEMALE" | "OTHER" | null; */ export function binaryReferenceSex( sex: string | null | undefined, -): "MALE" | "FEMALE" | null { +): BinaryReferenceSex | null { return sex === "MALE" || sex === "FEMALE" ? sex : null; } From 6ab3bc87ff1e0a1fafed51e6b0d4ede000a923ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:51:09 +0200 Subject: [PATCH 25/39] test(analytics): pin the effective-range resolver's sex-split defaults The resolver reads the profile's sex for the body-fat and pulse bands, and nothing asserted it. A resolver that stopped reading the field, or read it and defaulted to one side, passed the whole suite. Pin both recorded sides and the neutral band that OTHER and an unanswered profile share. --- .../__tests__/effective-range.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 8e6e756fe..4ce9905a8 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -90,6 +90,29 @@ describe("getEffectiveRange", () => { }); }); +// The resolver narrows the stored three-value sex to the two a sex-split +// reference table has rows for, and OTHER / "no answer" must land on the +// neutral band rather than on either side. Nothing pinned that before, so a +// resolver that quietly stopped reading the profile's sex — or read it and +// picked a default side — went unnoticed. +describe("sex-split defaults", () => { + const withGender = (gender: string | null) => + getEffectiveRange("BODY_FAT", { ...baseProfile, gender }, null).range!; + + it("gives each recorded side its own body-fat band", () => { + expect(withGender("MALE").greenMin).toBe(14); + expect(withGender("MALE").greenMax).toBe(24); + expect(withGender("FEMALE").greenMin).toBe(21); + expect(withGender("FEMALE").greenMax).toBe(31); + }); + + it("gives OTHER and an unanswered profile the same neutral band", () => { + const neutral = { greenMin: 17, greenMax: 27 }; + expect(withGender("OTHER")).toMatchObject(neutral); + expect(withGender(null)).toMatchObject(neutral); + }); +}); + describe("getAllEffectiveRanges", () => { it("returns an entry for every metric", () => { const ranges = getAllEffectiveRanges(baseProfile, null); From 146a02319d06dbe77536ef4492edf636f4b2252e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:57:25 +0200 Subject: [PATCH 26/39] refactor(medications): give the in-use clock list one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supply tab carried its own copy of the two container kinds whose first opening starts a degradation clock, with a comment saying it mirrored the state machine's list. Nothing enforced the mirror — the state machine types itself against the generated Prisma client, which the browser bundle cannot carry, so a copy was the only way to say it. Move the list into a client-safe module with no imports and let both sides read it. The state machine re-exports it under the Prisma enum, so a renamed or dropped container kind is now a compile error rather than a member that quietly stops matching. --- .../sections/inventory-section.tsx | 12 ++----- .../inventory/clock-container-types.ts | 33 +++++++++++++++++++ .../medications/inventory/state-machine.ts | 19 +++++++---- 3 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 src/lib/medications/inventory/clock-container-types.ts diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 6473f207f..c31979542 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -75,6 +75,7 @@ import { apiPost, apiPut, } from "@/lib/api/api-fetch"; +import { startsInUseClock } from "@/lib/medications/inventory/clock-container-types"; import type { SupplySummary } from "@/lib/medications/inventory/summary"; /** @@ -169,15 +170,6 @@ const STATE_BADGE: Record< USED_UP: "outline", }; -/** - * Container kinds whose first opening starts a 30-day degradation clock. - * Mirrors `IN_USE_CLOCK_CONTAINER_TYPES` in - * `src/lib/medications/inventory/state-machine.ts` — the server owns the - * transition; the client only needs to know which helper text to show - * beside the opening date. - */ -const CLOCK_CONTAINER_TYPES: readonly ContainerType[] = ["PEN", "AMPOULE"]; - /** Default container kind for the register flow, from the delivery form. */ function defaultContainerType(deliveryForm: string | undefined): ContainerType { if (deliveryForm === "INJECTION") return "PEN"; @@ -971,7 +963,7 @@ export function AdjustInventoryDialog({ id="inventory-edit-opened-helper" className="text-muted-foreground text-xs" > - {CLOCK_CONTAINER_TYPES.includes(item.containerType) + {startsInUseClock(item.containerType) ? t("medications.detail.bestand.editOpenedHelperClock") : t("medications.detail.bestand.editOpenedHelper")}

diff --git a/src/lib/medications/inventory/clock-container-types.ts b/src/lib/medications/inventory/clock-container-types.ts new file mode 100644 index 000000000..ed37c367f --- /dev/null +++ b/src/lib/medications/inventory/clock-container-types.ts @@ -0,0 +1,33 @@ +/** + * The one owner of "which container kinds start a degradation clock when + * they are first opened". + * + * The state machine decides the transition and the settings client decides + * which helper text to put beside the opening date. Both need the same + * list, but the state machine types itself against the generated Prisma + * client, which a browser bundle cannot carry — so the client kept its own + * copy of the two literals with a comment promising they matched. Nothing + * held the promise, and a release spent fixing a fact that drifted is a bad + * place to keep a second one. + * + * So the list lives here: string literals and a pure predicate, no imports, + * safe on either side of the boundary. `state-machine.ts` re-exports it + * under the Prisma enum (`satisfies`), which turns a renamed or dropped + * container kind into a compile error rather than a member that silently + * stops matching. The per-kind reasoning stays in the state machine's + * docblock, where the transition it drives is. + */ + +export const IN_USE_CLOCK_CONTAINER_TYPES = ["PEN", "AMPOULE"] as const; + +/** + * Does opening this container start the in-use window? Takes a loose + * `string` so the client can ask about a container kind it read off the + * wire without narrowing first; an unrecognised kind answers `false`, + * which is the honest side (no deadline nobody entered). + */ +export function startsInUseClock(containerType: string): boolean { + return (IN_USE_CLOCK_CONTAINER_TYPES as readonly string[]).includes( + containerType, + ); +} diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts index 10b49884c..e3c5ca27f 100644 --- a/src/lib/medications/inventory/state-machine.ts +++ b/src/lib/medications/inventory/state-machine.ts @@ -79,6 +79,10 @@ import type { MedicationContainerType, MedicationInventoryState, } from "@/generated/prisma/client"; +import { + IN_USE_CLOCK_CONTAINER_TYPES as CLOCK_CONTAINER_TYPES, + startsInUseClock, +} from "./clock-container-types"; /** Default in-use window per EMA EPAR §6.3 for the strictest GLP-1 * agonists (Mounjaro, Saxenda). 30 days. */ @@ -90,17 +94,18 @@ export const DEFAULT_IN_USE_WINDOW_DAYS = 30; * Every other kind holds individually sealed units (or an unknown * arrangement) and expires only on its printed date. See the module * docblock for the per-member reasoning. + * + * The list itself lives in `./clock-container-types`, which the settings + * client can import; `satisfies` here is the drift guard, so a container + * kind renamed or dropped from the column stops compiling instead of + * quietly never matching. */ -export const IN_USE_CLOCK_CONTAINER_TYPES = [ - "PEN", - "AMPOULE", -] as const satisfies readonly MedicationContainerType[]; +export const IN_USE_CLOCK_CONTAINER_TYPES = + CLOCK_CONTAINER_TYPES satisfies readonly MedicationContainerType[]; /** Does opening this container start the in-use window? */ export function hasInUseClock(containerType: MedicationContainerType): boolean { - return (IN_USE_CLOCK_CONTAINER_TYPES as readonly string[]).includes( - containerType, - ); + return startsInUseClock(containerType); } const MS_PER_DAY = 24 * 60 * 60 * 1000; From 1a6e523b29b456dbb1f9e70d139c7ff385ab82c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 14:13:41 +0200 Subject: [PATCH 27/39] refactor(medications): one admission path and one replay key for intake imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-medication import and the account-wide dose-history import queued their work through ninety near-identical lines each: row lock, stale-job sweep, active-job refusal, job row, pg-boss send, and the fallback that fails the row when the queue is not there. Five steps written twice drift, and the drift shows up as an import that hangs on one surface and not the other. The status projection moved out of these routes for the same reason; this is the write half. Scope is the only real difference, and it is one argument. What the two surfaces legitimately say differently — the 409 wording, the envelope, the kickoff audit line — stays at the call site. The replay key had the same shape of problem in smaller form: the export reader owned a function for it and the hand-listed body inlined the same template. Both now call one function, with a test that pins them equal for the same medication and instant. --- .../medications/[id]/intake/import/route.ts | 118 ++---------- .../intake/dose-history-import/route.ts | 111 ++--------- .../intake-import-idempotency-key.test.ts | 67 +++++++ .../medications/import/auto-export-plan.ts | 19 +- .../medications/intake-import-admission.ts | 182 ++++++++++++++++++ src/lib/medications/intake-import-payload.ts | 27 ++- 6 files changed, 306 insertions(+), 218 deletions(-) create mode 100644 src/lib/medications/__tests__/intake-import-idempotency-key.test.ts create mode 100644 src/lib/medications/intake-import-admission.ts diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index fb18c1866..90fa2ac87 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -11,16 +11,13 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; -import { prisma, toJson } from "@/lib/db"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { prisma } from "@/lib/db"; import { - MEDICATION_INTAKE_IMPORT_QUEUE, - MEDICATION_INTAKE_IMPORT_SEND_OPTIONS, - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, type MedicationImportPayload, type MedicationImportProgress, } from "@/lib/jobs/medication-intake-import"; import { annotate } from "@/lib/logging/context"; +import { admitIntakeImportJob } from "@/lib/medications/intake-import-admission"; import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -181,111 +178,26 @@ export const POST = apiHandler( touchedDays: [], rollupProcessed: 0, }; - const admission = await prisma.$transaction(async (tx) => { - await tx.$queryRaw` - SELECT "id" - FROM "medications" - WHERE "id" = ${id} - AND "user_id" = ${user.id} - FOR UPDATE - `; - const now = new Date(); - const staleBefore = new Date( - now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, - ); - await tx.medicationIntakeImportJob.updateMany({ - where: { - userId: user.id, - medicationId: id, - status: { in: ["queued", "running"] }, - OR: [ - { heartbeatAt: { lt: staleBefore } }, - { - heartbeatAt: null, - startedAt: { lt: staleBefore }, - }, - { - heartbeatAt: null, - startedAt: null, - createdAt: { lt: staleBefore }, - }, - ], - }, - data: { - status: "failed", - failureReason: "Medication intake import abandoned", - heartbeatAt: now, - completedAt: now, - }, - }); - const activeJob = await tx.medicationIntakeImportJob.findFirst({ - where: { - userId: user.id, - medicationId: id, - status: { in: ["queued", "running"] }, - }, - select: { id: true }, - }); - if (activeJob) return { active: true as const }; - - const importJob = await tx.medicationIntakeImportJob.create({ - data: { - userId: user.id, - medicationId: id, - status: "queued", - payload: toJson(normalized), - progress: toJson(progress), - }, - }); - return { active: false as const, importJob }; + const admission = await admitIntakeImportJob({ + userId: user.id, + medicationId: id, + payload: normalized, + progress, + ipAddress: getClientIp(request), }); - if (admission.active) { + if (!admission.admitted) { return apiError("Medication intake import already in progress", 409); } - const { importJob } = admission; - - let bossJobId: string | null = null; - try { - const boss = getGlobalBoss(); - if (!boss) throw new Error("worker unavailable"); - bossJobId = await boss.send( - MEDICATION_INTAKE_IMPORT_QUEUE, - { jobId: importJob.id }, - MEDICATION_INTAKE_IMPORT_SEND_OPTIONS, - ); - if (!bossJobId) throw new Error("queue rejected job"); - await prisma.medicationIntakeImportJob.update({ - where: { id: importJob.id }, - data: { pgBossJobId: bossJobId }, - }); - } catch { - const completedAt = new Date(); - await prisma.medicationIntakeImportJob.update({ - where: { id: importJob.id }, - data: { - status: "failed", - failureReason: "Background worker enqueue failed", - heartbeatAt: completedAt, - completedAt, - }, - }); - await auditLog("medication.intake.import.kickoff.denied", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - jobId: importJob.id, - medicationId: id, - reason: "enqueue_failed", - }, - }); + if (!admission.enqueued) { return apiError("Background worker is not available", 503); } + const { jobId, bossJobId } = admission; await auditLog("medication.intake.import.kickoff", { userId: user.id, ipAddress: getClientIp(request), details: { - jobId: importJob.id, + jobId, bossJobId, medicationId: id, total: normalized.entries.length, @@ -298,16 +210,16 @@ export const POST = apiHandler( entity_id: id, }, meta: { - job_id: importJob.id, + job_id: jobId, total: normalized.entries.length, }, }); return apiSuccess( { - jobId: importJob.id, + jobId, status: "queued" as const, - statusUrl: `/api/medications/${id}/intake/import/${importJob.id}/status`, + statusUrl: `/api/medications/${id}/intake/import/${jobId}/status`, }, 202, ); diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts index df4150bd6..362840a42 100644 --- a/src/app/api/medications/intake/dose-history-import/route.ts +++ b/src/app/api/medications/intake/dose-history-import/route.ts @@ -32,18 +32,15 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; -import { prisma, toJson } from "@/lib/db"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { prisma } from "@/lib/db"; import { - MEDICATION_INTAKE_IMPORT_QUEUE, - MEDICATION_INTAKE_IMPORT_SEND_OPTIONS, - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, groupMedicationImportSkips, totalMedicationImportSkips, type MedicationImportPayload, type MedicationImportProgress, } from "@/lib/jobs/medication-intake-import"; import { annotate } from "@/lib/logging/context"; +import { admitIntakeImportJob } from "@/lib/medications/intake-import-admission"; import { parseAutoExportCsv, parseAutoExportJson, @@ -190,104 +187,28 @@ export const POST = apiHandler(async (request: NextRequest) => { rollupProcessed: 0, }; - const admission = await prisma.$transaction(async (tx) => { - await tx.$queryRaw` - SELECT "id" - FROM "users" - WHERE "id" = ${user.id} - FOR UPDATE - `; - const now = new Date(); - const staleBefore = new Date( - now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, - ); - await tx.medicationIntakeImportJob.updateMany({ - where: { - userId: user.id, - medicationId: null, - status: { in: ["queued", "running"] }, - OR: [ - { heartbeatAt: { lt: staleBefore } }, - { heartbeatAt: null, startedAt: { lt: staleBefore } }, - { - heartbeatAt: null, - startedAt: null, - createdAt: { lt: staleBefore }, - }, - ], - }, - data: { - status: "failed", - failureReason: "Medication intake import abandoned", - heartbeatAt: now, - completedAt: now, - }, - }); - const activeJob = await tx.medicationIntakeImportJob.findFirst({ - where: { - userId: user.id, - medicationId: null, - status: { in: ["queued", "running"] }, - }, - select: { id: true }, - }); - if (activeJob) return { active: true as const }; - - const importJob = await tx.medicationIntakeImportJob.create({ - data: { - userId: user.id, - medicationId: null, - status: "queued", - payload: toJson(payload), - progress: toJson(progress), - }, - }); - return { active: false as const, importJob }; + const admission = await admitIntakeImportJob({ + userId: user.id, + medicationId: null, + payload, + progress, + ipAddress: getClientIp(request), }); - if (admission.active) { + if (!admission.admitted) { return apiError("A dose-history import is already in progress", 409); } - const { importJob } = admission; - - const statusUrl = `/api/medications/intake/dose-history-import/${importJob.id}/status`; - let bossJobId: string | null = null; - try { - const boss = getGlobalBoss(); - if (!boss) throw new Error("worker unavailable"); - bossJobId = await boss.send( - MEDICATION_INTAKE_IMPORT_QUEUE, - { jobId: importJob.id }, - MEDICATION_INTAKE_IMPORT_SEND_OPTIONS, - ); - if (!bossJobId) throw new Error("queue rejected job"); - await prisma.medicationIntakeImportJob.update({ - where: { id: importJob.id }, - data: { pgBossJobId: bossJobId }, - }); - } catch { - const completedAt = new Date(); - await prisma.medicationIntakeImportJob.update({ - where: { id: importJob.id }, - data: { - status: "failed", - failureReason: "Background worker enqueue failed", - heartbeatAt: completedAt, - completedAt, - }, - }); - await auditLog("medication.intake.import.kickoff.denied", { - userId: user.id, - ipAddress: getClientIp(request), - details: { jobId: importJob.id, reason: "enqueue_failed" }, - }); + if (!admission.enqueued) { return apiError("Background worker is not available", 503); } + const { jobId, bossJobId } = admission; + + const statusUrl = `/api/medications/intake/dose-history-import/${jobId}/status`; await auditLog("medication.intake.history.import.kickoff", { userId: user.id, ipAddress: getClientIp(request), details: { - jobId: importJob.id, + jobId, bossJobId, rowsRead: file.rowsRead, queued: file.queued, @@ -297,7 +218,7 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "medication.intake.history.import.kickoff" }, meta: { - job_id: importJob.id, + job_id: jobId, rows_read: file.rowsRead, queued: file.queued, refused: file.refused, @@ -307,7 +228,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiSuccess( { dryRun: false, - jobId: importJob.id, + jobId, status: "queued" as const, statusUrl, file, diff --git a/src/lib/medications/__tests__/intake-import-idempotency-key.test.ts b/src/lib/medications/__tests__/intake-import-idempotency-key.test.ts new file mode 100644 index 000000000..c981d5012 --- /dev/null +++ b/src/lib/medications/__tests__/intake-import-idempotency-key.test.ts @@ -0,0 +1,67 @@ +/** + * Both intake-import surfaces must derive the same replay key. + * + * The key is what makes a re-run cost nothing: it matches the live-row unique + * on `(user_id, medication_id, scheduled_for, source)`, so a second submission + * of the same dose collides instead of writing a second ledger row. The two + * surfaces — the hand-listed per-medication body and the export reader — used + * to build the string separately, which meant a change to one silently stopped + * the other from deduplicating its replays. They now share one function; this + * pins that they agree for the same medication and instant, so a future split + * fails here rather than in a user's ledger. + */ +import { describe, expect, it } from "vitest"; + +import { planAutoExportImport } from "../import/auto-export-plan"; +import { parseAutoExportCsv } from "../import/auto-export-parse"; +import { + buildMedicationImportEntries, + medicationImportIdempotencyKey, +} from "../intake-import-payload"; + +const MEDICATION_ID = "med_shared"; +/** The instant both surfaces are asked about, expressed each one's own way. */ +const LOCAL_DATE = "2025-03-09"; +const LOCAL_TIME = "07:30"; +const INSTANT = new Date(`${LOCAL_DATE}T${LOCAL_TIME}`); + +const CSV_HEADER = + "Date,Scheduled Date,Medication,Nickname,Dose Taken,Dose,Unit,Status,Skipped,Notes"; + +function offsetSuffix(at: Date): string { + const minutes = -at.getTimezoneOffset(); + const sign = minutes < 0 ? "-" : "+"; + const abs = Math.abs(minutes); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`; +} + +describe("intake-import replay keys", () => { + it("derives the key from the medication and the slot instant only", () => { + expect(medicationImportIdempotencyKey(MEDICATION_ID, INSTANT)).toBe( + `import-${MEDICATION_ID}-${INSTANT.getTime()}`, + ); + }); + + it("gives the hand-listed body and the export reader the same key", () => { + const [fromBody] = buildMedicationImportEntries(MEDICATION_ID, [ + { datum: LOCAL_DATE, uhrzeit: LOCAL_TIME }, + ]); + + // The export names its instants with an explicit offset, so the same wall + // time is written here with the local offset the body row implies. + const stamp = `${LOCAL_DATE} ${LOCAL_TIME}:00 ${offsetSuffix(INSTANT)}`; + const parsed = parseAutoExportCsv( + `${CSV_HEADER}\n${stamp},${stamp},Ramipril,,1.0,1.0,count,Taken,No,\n`, + ); + const plan = planAutoExportImport(parsed, [ + { id: MEDICATION_ID, name: "Ramipril", externalSource: null }, + ]); + + expect(plan.entries).toHaveLength(1); + expect(plan.entries[0].idempotencyKey).toBe(fromBody.idempotencyKey); + expect(fromBody.idempotencyKey).toBe( + medicationImportIdempotencyKey(MEDICATION_ID, INSTANT), + ); + }); +}); diff --git a/src/lib/medications/import/auto-export-plan.ts b/src/lib/medications/import/auto-export-plan.ts index 5ca81b81c..a2c3f41ea 100644 --- a/src/lib/medications/import/auto-export-plan.ts +++ b/src/lib/medications/import/auto-export-plan.ts @@ -14,6 +14,7 @@ import type { MedicationImportSkipReason, } from "@/lib/jobs/medication-intake-import"; +import { medicationImportIdempotencyKey } from "@/lib/medications/intake-import-payload"; import { normaliseMedicationName } from "./auto-export-format"; import type { AutoExportDose, @@ -62,22 +63,6 @@ export interface AutoExportPlan { unknownColumns: string[]; } -/** - * An imported dose is identified by its medication and its slot instant. - * - * That is the grain the database already enforces through the live-row unique on - * `(user_id, medication_id, scheduled_for, source)`, so the replay key is a - * function of exactly the same facts. It was previously built from an optional - * counter field in the request body, which collapsed a month of distinct doses - * onto one key as soon as an export put something non-unique there. - */ -export function autoExportIdempotencyKey( - medicationId: string, - scheduledFor: Date, -): string { - return `import-${medicationId}-${scheduledFor.getTime()}`; -} - function bump( counts: MedicationImportSkipCounts, reason: MedicationImportSkipReason, @@ -178,7 +163,7 @@ export function planAutoExportImport( continue; } - const idempotencyKey = autoExportIdempotencyKey( + const idempotencyKey = medicationImportIdempotencyKey( match.id, dose.scheduledFor, ); diff --git a/src/lib/medications/intake-import-admission.ts b/src/lib/medications/intake-import-admission.ts new file mode 100644 index 000000000..4feacd5d6 --- /dev/null +++ b/src/lib/medications/intake-import-admission.ts @@ -0,0 +1,182 @@ +/** + * The one admission path for an intake-import job. + * + * Two routes queue this work — the per-medication import under + * `/api/medications/[id]/…` and the account-wide dose-history import — and both + * have to do the same five things in the same order: take a row lock so two + * submissions cannot race past each other, fail out any job the worker + * abandoned, refuse if one is genuinely still running, write the job row, and + * hand it to pg-boss with a fallback that marks the row failed when the queue + * is not there. Written twice, those five steps drift: one route grows a + * staleness clause the other does not, and the difference only shows up as an + * import that hangs forever on one surface and not the other. + * + * `readMedicationIntakeImportJob` took the read half of this pair out of the + * routes for the same reason. This is the write half. + * + * The only real difference between the two callers is scope, and it is one + * argument: `medicationId` narrows the lock, the active-job check and the job + * row to a single medication, `null` means the whole account. Everything the + * caller still owns — the 409 wording, the success envelope, the kickoff audit + * line — stays at the call site, because those are the parts the two surfaces + * legitimately say differently. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + MEDICATION_INTAKE_IMPORT_QUEUE, + MEDICATION_INTAKE_IMPORT_SEND_OPTIONS, + MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, + type MedicationImportPayload, + type MedicationImportProgress, +} from "@/lib/jobs/medication-intake-import"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma, toJson } from "@/lib/db"; + +export interface IntakeImportAdmissionInput { + userId: string; + /** `null` = the account-wide dose-history import. */ + medicationId: string | null; + payload: MedicationImportPayload; + progress: MedicationImportProgress; + /** Threaded onto the enqueue-failure audit row; `null` when unresolved. */ + ipAddress: string | null; +} + +export type IntakeImportAdmission = + /** An import for this scope is already queued or running. */ + | { admitted: false } + /** + * The job row exists but the queue would not take it, so the row is already + * marked failed and the audit line is written. The caller answers 503. + */ + | { admitted: true; enqueued: false; jobId: string } + | { admitted: true; enqueued: true; jobId: string; bossJobId: string }; + +/** + * Claim the import slot for one scope and hand the job to the worker. + * + * The lock is taken on the row the scope belongs to — the medication for a + * per-medication import, the account for the dose-history import — so two + * submissions serialise against each other without blocking an unrelated one. + */ +export async function admitIntakeImportJob({ + userId, + medicationId, + payload, + progress, + ipAddress, +}: IntakeImportAdmissionInput): Promise { + const admission = await prisma.$transaction(async (tx) => { + if (medicationId === null) { + await tx.$queryRaw` + SELECT "id" + FROM "users" + WHERE "id" = ${userId} + FOR UPDATE + `; + } else { + await tx.$queryRaw` + SELECT "id" + FROM "medications" + WHERE "id" = ${medicationId} + AND "user_id" = ${userId} + FOR UPDATE + `; + } + + const now = new Date(); + const staleBefore = new Date( + now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, + ); + // A worker that died mid-run leaves a row that says "running" forever, and + // the active-job check below would then refuse every later submission. The + // three clauses cover the three points a run can stop at: after a + // heartbeat, after the start, and before either. + await tx.medicationIntakeImportJob.updateMany({ + where: { + userId, + medicationId, + status: { in: ["queued", "running"] }, + OR: [ + { heartbeatAt: { lt: staleBefore } }, + { heartbeatAt: null, startedAt: { lt: staleBefore } }, + { + heartbeatAt: null, + startedAt: null, + createdAt: { lt: staleBefore }, + }, + ], + }, + data: { + status: "failed", + failureReason: "Medication intake import abandoned", + heartbeatAt: now, + completedAt: now, + }, + }); + + const activeJob = await tx.medicationIntakeImportJob.findFirst({ + where: { + userId, + medicationId, + status: { in: ["queued", "running"] }, + }, + select: { id: true }, + }); + if (activeJob) return { active: true as const }; + + const importJob = await tx.medicationIntakeImportJob.create({ + data: { + userId, + medicationId, + status: "queued", + payload: toJson(payload), + progress: toJson(progress), + }, + }); + return { active: false as const, importJob }; + }); + + if (admission.active) return { admitted: false }; + const jobId = admission.importJob.id; + + try { + const boss = getGlobalBoss(); + if (!boss) throw new Error("worker unavailable"); + const bossJobId = await boss.send( + MEDICATION_INTAKE_IMPORT_QUEUE, + { jobId }, + MEDICATION_INTAKE_IMPORT_SEND_OPTIONS, + ); + if (!bossJobId) throw new Error("queue rejected job"); + await prisma.medicationIntakeImportJob.update({ + where: { id: jobId }, + data: { pgBossJobId: bossJobId }, + }); + return { admitted: true, enqueued: true, jobId, bossJobId }; + } catch { + // The row is already written, so leaving it queued would park the scope + // behind a job nobody will ever pick up. Fail it here and the next + // submission is admitted immediately. + const completedAt = new Date(); + await prisma.medicationIntakeImportJob.update({ + where: { id: jobId }, + data: { + status: "failed", + failureReason: "Background worker enqueue failed", + heartbeatAt: completedAt, + completedAt, + }, + }); + await auditLog("medication.intake.import.kickoff.denied", { + userId, + ipAddress, + details: { + jobId, + ...(medicationId === null ? {} : { medicationId }), + reason: "enqueue_failed", + }, + }); + return { admitted: true, enqueued: false, jobId }; + } +} diff --git a/src/lib/medications/intake-import-payload.ts b/src/lib/medications/intake-import-payload.ts index da60d6944..a1d13d85e 100644 --- a/src/lib/medications/intake-import-payload.ts +++ b/src/lib/medications/intake-import-payload.ts @@ -19,8 +19,15 @@ export interface MedicationImportQueueEntry { } /** - * An imported dose is identified by its medication and its instant, and by - * nothing else. + * The replay key for one imported dose: its medication and its slot instant, + * and nothing else. + * + * That is the grain the database already enforces through the live-row unique + * on `(user_id, medication_id, scheduled_for, source)`, so the key is a + * function of exactly the same facts. Both import surfaces call it — the + * hand-listed per-medication body and the export reader — because a key + * written out twice is a key that can disagree with itself, and two callers + * that disagree stop deduplicating each other's replays. * * The key used to be built from the row's optional `zaehler` field whenever it * was present. That reads as a per-row counter, but nothing required it to be @@ -33,6 +40,20 @@ export interface MedicationImportQueueEntry { * and matches the live-row unique on `(user_id, medication_id, scheduled_for, * source)` that the database enforces regardless. */ +export function medicationImportIdempotencyKey( + medicationId: string, + scheduledFor: Date, +): string { + return `import-${medicationId}-${scheduledFor.getTime()}`; +} + +/** + * Derive the queued entries for a hand-listed import body. + * + * It lives here rather than inline in the route so the test that proves a + * reported export lands its rows can drive the real derivation instead of + * restating it. A test that mirrors the production formula proves the mirror. + */ export function buildMedicationImportEntries( medicationId: string, rows: readonly MedicationImportRow[], @@ -46,7 +67,7 @@ export function buildMedicationImportEntries( // The export-reading route is the one that has both facts to give. scheduledFor: takenAt.toISOString(), takenAt: takenAt.toISOString(), - idempotencyKey: `import-${medicationId}-${takenAt.getTime()}`, + idempotencyKey: medicationImportIdempotencyKey(medicationId, takenAt), }; }); } From 1c05bba56c1b866392a5f18dd6a744aca3f393b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 14:14:31 +0200 Subject: [PATCH 28/39] docs(jobs): correct two migration citations and the import dedup contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retention-purge comments still cite migration 0277 for indexes that landed in 0276 — the renumbering moved the directories and left the prose behind. The schema is trustworthy because its comments are, so a wrong number is not cosmetic. The import worker's `already_recorded` reason claimed the skip covers a dose recorded on any other intake surface. It does not: `source` is part of the live-row unique, so a hand-logged dose and an imported one at the same slot are different keys and both are written. Say what the mechanism actually does, and say plainly that cross-surface dedup is not there. --- src/lib/jobs/audit-log-cleanup.ts | 2 +- src/lib/jobs/measurement-tombstone-cleanup.ts | 2 +- src/lib/jobs/medication-intake-import.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/jobs/audit-log-cleanup.ts b/src/lib/jobs/audit-log-cleanup.ts index d4294805f..b79ba31b3 100644 --- a/src/lib/jobs/audit-log-cleanup.ts +++ b/src/lib/jobs/audit-log-cleanup.ts @@ -37,7 +37,7 @@ export function getAuditLogRetentionDays(): number { * on every removed row. Past the 60-second `statement_timeout` that aborts and * rolls back, and the next night repeats it against the same rows — a * retention window that can never close on an instance that has accumulated - * enough of them. Migration 0277 adds the `created_at` index; the batching + * enough of them. Migration 0276 adds the `created_at` index; the batching * keeps any single statement bounded regardless. */ export async function cleanupOldAuditLogs( diff --git a/src/lib/jobs/measurement-tombstone-cleanup.ts b/src/lib/jobs/measurement-tombstone-cleanup.ts index 9431320c6..43c2f5db5 100644 --- a/src/lib/jobs/measurement-tombstone-cleanup.ts +++ b/src/lib/jobs/measurement-tombstone-cleanup.ts @@ -38,7 +38,7 @@ function tombstoneCutoff(now: Date): Date { * the transaction rolled back, and the next night ran the identical statement * against the same rows. `measurements` is the densest table in the schema and * a bulk source delete can tombstone a six-figure row count in one action, so - * this is the one most likely to have been stuck. Migration 0277 adds the + * this is the one most likely to have been stuck. Migration 0276 adds the * partial index the predicate needs. */ export async function cleanupExpiredMeasurementTombstones( diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 2fa7f0228..2d0a365e9 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -58,8 +58,13 @@ export interface MedicationImportPayload { * * `duplicate_in_file` — two rows of the same submission resolve to the same * medication and slot, so only one can be written. - * `already_recorded` — the dose is already on the ledger for that slot, from an - * earlier import or from any other intake surface. + * `already_recorded` — the dose is already on the ledger for that slot from an + * earlier import. `source` is part of the live-row unique + * `(user_id, medication_id, scheduled_for, source)`, so this does NOT cover a + * dose the person logged by hand at the same slot on another surface: that row + * and the imported one are distinct keys and both are written. Cross-surface + * dedup is a slot-window question, not a unique-key one, and it does not exist + * yet — do not read this reason as though it did. * * Those two used to be one `skippedDuplicates` number, which is how a file whose * every row collapsed onto one key could report "27 duplicates skipped" about 27 From 832a269ab3de4942e939162ac953281bd89ebf97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 14:25:13 +0200 Subject: [PATCH 29/39] refactor(medications): split the supply tab at the dialog boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bestand tab was 1,139 lines of one client file holding two concerns that only meet at the row shape: the list of containers a person holds, and the three dialogs that change it. Registration, per-item correction, packaging economics, the summary and the row rendering all shared a scope, so reading any one of them meant scrolling past the rest. Move-only. The dialogs go to `inventory-dialogs.tsx` with the day-input conversions their fields produce; the row shape and the cache-invalidation they both need go to `inventory-shared.ts`; the section keeps the summary and the list at 458 lines. Every export the tab offered is still offered here, so no caller changes. No copy changes, no new keys. Two suites pin these contracts by reading the file's own source — the register dialog's unit conversion and the correction dialog's sparse patch. Their `readFileSync` now names the file the code moved to. The assertions are unchanged, byte for byte; only the path they read is. --- .../__tests__/add-inventory-dialog.test.tsx | 2 +- .../inventory-section-dates.test.tsx | 2 +- .../sections/inventory-dialogs.tsx | 666 ++++++++++++++++ .../sections/inventory-section.tsx | 717 +----------------- .../medications/sections/inventory-shared.ts | 63 ++ 5 files changed, 753 insertions(+), 697 deletions(-) create mode 100644 src/components/medications/sections/inventory-dialogs.tsx create mode 100644 src/components/medications/sections/inventory-shared.ts diff --git a/src/components/medications/__tests__/add-inventory-dialog.test.tsx b/src/components/medications/__tests__/add-inventory-dialog.test.tsx index 949394327..d6861fec3 100644 --- a/src/components/medications/__tests__/add-inventory-dialog.test.tsx +++ b/src/components/medications/__tests__/add-inventory-dialog.test.tsx @@ -77,7 +77,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "de"): string { } const src = readFileSync( - resolve(__dirname, "../sections/inventory-section.tsx"), + resolve(__dirname, "../sections/inventory-dialogs.tsx"), "utf8", ); diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx index fe9475895..2e52e26dc 100644 --- a/src/components/medications/__tests__/inventory-section-dates.test.tsx +++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx @@ -21,7 +21,7 @@ import { I18nProvider } from "@/lib/i18n/context"; const src = readFileSync( resolve( process.cwd(), - "src/components/medications/sections/inventory-section.tsx", + "src/components/medications/sections/inventory-dialogs.tsx", ), "utf8", ); diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx new file mode 100644 index 000000000..37e586af2 --- /dev/null +++ b/src/components/medications/sections/inventory-dialogs.tsx @@ -0,0 +1,666 @@ +"use client"; + +/** + * The supply tab's write dialogs: register a container, correct one, and edit + * the medication-level packaging the register flow prefills from. + * + * They sit beside the list rather than inside it because they are the other + * half of the tab: the list answers "what do I hold", these answer "change + * it". Sharing one file made a single client component out of two concerns + * that only meet at the row shape, and the shape now lives in + * `./inventory-shared`. + * + * Both dates a container carries are entered — and stored — as a calendar day + * at the browser's local midnight, so the conversions live here too, next to + * the fields that produce them. + */ + +import { useId, useState, type FormEvent } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { startsInUseClock } from "@/lib/medications/inventory/clock-container-types"; +import { + CONTAINER_TYPES, + invalidateSupplyQueries, + type ContainerType, + type InventoryItem, +} from "./inventory-shared"; + +/** + * Both dates the row carries are entered — and were always stored — as a + * calendar day at the browser's local midnight. Reading them back in the + * same zone is what makes the value the user typed the value they see; + * pinning to UTC would slide the day for anyone east of Greenwich. + */ +export function toLocalDayInput(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** The inverse: a `yyyy-MM-dd` day back to the instant the API stores. */ +export function fromLocalDayInput(day: string): string | null { + if (!day) return null; + const at = new Date(`${day}T00:00:00`); + return Number.isNaN(at.getTime()) ? null : at.toISOString(); +} + +/** Default container kind for the register flow, from the delivery form. */ +export function defaultContainerType( + deliveryForm: string | undefined, +): ContainerType { + if (deliveryForm === "INJECTION") return "PEN"; + if (deliveryForm === "ORAL") return "BLISTER"; + return "OTHER"; +} + +/** + * Register a new pack / container. Quantity (units), container type and + * optional printed expiry → Dialog per ui-guidelines §2.3. The quantity + * prefills from the medication's `dosesPerUnit` so the common case is + * one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented + * Dosen | Einheiten control converts live — the POST always carries + * units. + */ +export function AddInventoryDialog({ + medicationId, + defaultUnitsTotal, + unitsPerDose, + initialContainerType, + onClose, +}: { + medicationId: string; + defaultUnitsTotal: number | null; + unitsPerDose: number; + initialContainerType: ContainerType; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [quantity, setQuantity] = useState( + defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000 + ? String(defaultUnitsTotal) + : "", + ); + // The unit the typed quantity is read in. Only surfaced when a dose + // spans several units; the stored value is ALWAYS units. + const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units"); + const [containerType, setContainerType] = + useState(initialContainerType); + const [expiry, setExpiry] = useState(""); + // Carton labelling. Only offered for a PEN: the native pen list is the + // one surface that renders them, and asking for a maker + strength on a + // blister pack would be noise on the far commoner path. + const [manufacturer, setManufacturer] = useState(""); + const [doseStrength, setDoseStrength] = useState(""); + const [busy, setBusy] = useState(false); + const formId = useId(); + + const parsed = Number(quantity); + const effectiveMode = unitsPerDose > 1 ? quantityMode : "units"; + const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed; + const quantityValid = + Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000; + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!quantityValid || busy) return; + setBusy(true); + try { + await apiPost(`/api/medications/${medicationId}/inventory`, { + // The wire field carries UNITS (v1.16.10 symmetric naming). + unitsTotal: units, + containerType, + printedExpiry: expiry + ? new Date(`${expiry}T00:00:00`).toISOString() + : null, + // Trimmed to null so a field the user opened and left blank stores + // absence rather than an empty string. + manufacturer: manufacturer.trim() || null, + doseStrength: doseStrength.trim() || null, + }); + await invalidateSupplyQueries(queryClient, medicationId); + toast.success(t("medications.detail.bestand.addSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.addFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()} + title={t("medications.detail.bestand.addTitle")} + description={t("medications.detail.bestand.addDescription")} + footer={ + <> + + + + } + > +
+
+ + +
+
+ + {unitsPerDose > 1 && ( +
+ + +
+ )} + setQuantity(e.target.value)} + aria-describedby="inventory-add-quantity-helper" + /> + {unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && ( +

+ {effectiveMode === "doses" + ? t("medications.detail.bestand.quantityInUnits", { units }) + : // A unit count below one dose must not read "≈ 0 doses" — + // it is simply less than one dose. + Math.floor(parsed / unitsPerDose) === 0 + ? t("medications.detail.bestand.quantityUnderOneDose") + : t("medications.detail.bestand.quantityInDoses", { + doses: Math.floor(parsed / unitsPerDose), + })} +

+ )} +

+ {t("medications.detail.bestand.addQuantityHelper")} +

+
+
+ + +
+ {containerType === "PEN" && ( + <> +
+ + setManufacturer(e.target.value)} + /> +
+
+ + setDoseStrength(e.target.value)} + /> +

+ {t("medications.detail.bestand.addDoseStrengthHint")} +

+
+ + )} +
+
+ ); +} + +/** + * Correct one container: the remaining-unit count (covers both "I + * miscounted" and a manual withdrawal), the carton-printed expiry, and + * the date it was opened. The server clamps the count to the item's + * capacity and re-derives the state from the two dates; 0 units marks + * the item used up. + * + * Both dates are editable because both decide the state — and the + * opening date in particular is written by the intake consumption hook + * without the user ever typing it, so an auto-open on a back-dated dose + * needs a way back. Each field is sent only when it actually changed, + * so saving a unit correction never rewrites a date the user did not + * touch. + */ +export function AdjustInventoryDialog({ + medicationId, + item, + onClose, +}: { + medicationId: string; + item: InventoryItem; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + // v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty, + // not the literal "null"; the operator types the corrected figure. + const [value, setValue] = useState( + item.unitsRemaining == null ? "" : String(item.unitsRemaining), + ); + const initialExpiry = toLocalDayInput(item.printedExpiry); + const initialOpened = toLocalDayInput(item.firstUseAt); + const [expiry, setExpiry] = useState(initialExpiry); + const [opened, setOpened] = useState(initialOpened); + const [busy, setBusy] = useState(false); + const formId = useId(); + + const parsed = Number(value); + // v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5). + // v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness. + const valid = + value.trim() !== "" && + Number.isFinite(parsed) && + parsed >= 0 && + (item.unitsTotal == null || parsed <= item.unitsTotal); + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!valid || busy) return; + setBusy(true); + try { + await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, { + // The wire field carries UNITS (v1.16.10 symmetric naming). + unitsRemaining: parsed, + // Absent means untouched on the server, so only changed fields + // ride along. A cleared field sends null, which is a deliberate + // "there is no such date" — not the same as leaving it alone. + ...(expiry !== initialExpiry && { + printedExpiry: fromLocalDayInput(expiry), + }), + ...(opened !== initialOpened && { + markAsFirstUseAt: fromLocalDayInput(opened), + }), + }); + await invalidateSupplyQueries(queryClient, medicationId); + toast.success(t("medications.detail.bestand.adjustSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.adjustFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()} + title={t("medications.detail.bestand.adjustTitle")} + description={t("medications.detail.bestand.adjustDescription")} + footer={ + <> + + + + } + > +
+
+ + setValue(e.target.value)} + aria-describedby="inventory-adjust-helper" + /> +

+ {t("medications.detail.bestand.adjustHelper", { + total: item.unitsTotal ?? t("medications.detail.bestand.unknown"), + })} +

+
+
+ + +

+ {t("medications.detail.bestand.editExpiryHelper")} +

+
+
+ + +

+ {startsInUseClock(item.containerType) + ? t("medications.detail.bestand.editOpenedHelperClock") + : t("medications.detail.bestand.editOpenedHelper")} +

+
+
+
+ ); +} + +/** + * Edit the medication-level packaging economics from the supply tab: + * units one dose consumes, and the default container size the register + * flow prefills. Both PUT sparsely onto the medication — the wizard's + * dose step stays the source on create; this is the correction surface + * for a manufacturer switch (same medication, different blister size). + */ +export function PackagingDialog({ + medicationId, + unitsPerDose, + dosesPerUnit, + onClose, +}: { + medicationId: string; + unitsPerDose: number; + dosesPerUnit: number | null; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose)); + const [packValue, setPackValue] = useState( + dosesPerUnit === null ? "" : String(dosesPerUnit), + ); + const [busy, setBusy] = useState(false); + const formId = useId(); + + const parsedPerDose = Number(perDoseValue); + const perDoseValid = + Number.isInteger(parsedPerDose) && + parsedPerDose >= 1 && + parsedPerDose <= 100; + const parsedPack = packValue.trim() === "" ? null : Number(packValue); + const packValid = + parsedPack === null || + (Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000); + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!perDoseValid || !packValid || busy) return; + setBusy(true); + try { + await apiPut(`/api/medications/${medicationId}`, { + unitsPerDose: parsedPerDose, + dosesPerUnit: parsedPack, + }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationDetail(medicationId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.medications(), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }), + ]); + toast.success(t("medications.detail.bestand.packagingSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.packagingFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()} + title={t("medications.detail.bestand.packagingTitle")} + description={t("medications.detail.bestand.packagingDescription")} + footer={ + <> + + + + } + > +
+
+ + setPerDoseValue(e.target.value)} + aria-describedby="packaging-units-per-dose-helper" + /> +

+ {t("medications.detail.bestand.packagingUnitsPerDoseHelper")} +

+
+
+ + setPackValue(e.target.value)} + aria-describedby="packaging-default-pack-helper" + /> +

+ {t("medications.detail.bestand.packagingDefaultPackHelper")} +

+
+
+
+ ); +} diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index c31979542..11e493eb6 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -37,118 +37,45 @@ * doses remaining across every non-terminal item — plus a per-item list * with its container type and state badge. Shown for ALL medications * (pill packs count too). + * + * This file is the read half: the summary, the packaging line, and the + * per-item rows. The three write dialogs live in `./inventory-dialogs`, + * and the row shape they both speak in `./inventory-shared`. They are + * re-exported here so the tab keeps one import surface. */ -import { useId, useState, type FormEvent } from "react"; +import { useState } from "react"; import Link from "next/link"; -import { - useQuery, - useQueryClient, - type QueryClient, -} from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2, PackageOpen, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DeleteButton } from "@/components/data-list/delete-button"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { DateField } from "@/components/ui/date-field"; import { EmptyState } from "@/components/ui/empty-state"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { SettingsGroup } from "@/components/medications/settings-group"; import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/date-format"; import { queryKeys } from "@/lib/query-keys"; import { formatUnitCount } from "@/components/medications/units-per-dose"; -import { - apiDelete, - apiGet, - apiPatch, - apiPost, - apiPut, -} from "@/lib/api/api-fetch"; -import { startsInUseClock } from "@/lib/medications/inventory/clock-container-types"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import type { SupplySummary } from "@/lib/medications/inventory/summary"; +import { + AddInventoryDialog, + AdjustInventoryDialog, + PackagingDialog, + defaultContainerType, + toLocalDayInput, +} from "./inventory-dialogs"; +import { + invalidateSupplyQueries, + type InventoryItem, + type InventoryState, +} from "./inventory-shared"; -/** - * Invalidate every read key whose payload reflects a medication's supply - * after a container write (register / adjust / delete). - * - * The per-medication inventory read (`medicationInventory`) is the supply - * tab's own list. The medications LIST read (`medications`) carries the - * dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that - * the card and table render — without invalidating it, the card kept - * showing the pre-write stock until an unrelated refetch landed (the - * supply-staleness bug). Both keys must drop together. - */ -export async function invalidateSupplyQueries( - queryClient: QueryClient, - medicationId: string, -): Promise { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationInventory(medicationId), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.medications(), - }), - ]); -} - -type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; - -const CONTAINER_TYPES = [ - "PEN", - "AMPOULE", - "BLISTER", - "INHALER", - "BOTTLE", - "OTHER", -] as const; -type ContainerType = (typeof CONTAINER_TYPES)[number]; - -interface InventoryItem { - id: string; - state: InventoryState; - containerType: ContainerType; - // v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row); - // the UI renders "—" instead of a fabricated 0. - unitsTotal: number | null; - unitsRemaining: number | null; - /** ISO instant. The carton-printed expiry; null when none was recorded. */ - printedExpiry: string | null; - /** ISO instant. When the container was opened; null while sealed. */ - firstUseAt: string | null; -} - -/** - * Both dates the row carries are entered — and were always stored — as a - * calendar day at the browser's local midnight. Reading them back in the - * same zone is what makes the value the user typed the value they see; - * pinning to UTC would slide the day for anyone east of Greenwich. - */ -function toLocalDayInput(iso: string | null | undefined): string { - if (!iso) return ""; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ""; - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; -} - -/** The inverse: a `yyyy-MM-dd` day back to the instant the API stores. */ -function fromLocalDayInput(day: string): string | null { - if (!day) return null; - const at = new Date(`${day}T00:00:00`); - return Number.isNaN(at.getTime()) ? null : at.toISOString(); -} +export { AddInventoryDialog, AdjustInventoryDialog } from "./inventory-dialogs"; +export { invalidateSupplyQueries } from "./inventory-shared"; interface InventoryResponse { items: InventoryItem[]; @@ -170,13 +97,6 @@ const STATE_BADGE: Record< USED_UP: "outline", }; -/** Default container kind for the register flow, from the delivery form. */ -function defaultContainerType(deliveryForm: string | undefined): ContainerType { - if (deliveryForm === "INJECTION") return "PEN"; - if (deliveryForm === "ORAL") return "BLISTER"; - return "OTHER"; -} - export function InventorySection({ medicationId, dosesPerUnit, @@ -536,596 +456,3 @@ export function InventorySection({ ); } - -/** - * Register a new pack / container. Quantity (units), container type and - * optional printed expiry → Dialog per ui-guidelines §2.3. The quantity - * prefills from the medication's `dosesPerUnit` so the common case is - * one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented - * Dosen | Einheiten control converts live — the POST always carries - * units. - */ -export function AddInventoryDialog({ - medicationId, - defaultUnitsTotal, - unitsPerDose, - initialContainerType, - onClose, -}: { - medicationId: string; - defaultUnitsTotal: number | null; - unitsPerDose: number; - initialContainerType: ContainerType; - onClose: () => void; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [quantity, setQuantity] = useState( - defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000 - ? String(defaultUnitsTotal) - : "", - ); - // The unit the typed quantity is read in. Only surfaced when a dose - // spans several units; the stored value is ALWAYS units. - const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units"); - const [containerType, setContainerType] = - useState(initialContainerType); - const [expiry, setExpiry] = useState(""); - // Carton labelling. Only offered for a PEN: the native pen list is the - // one surface that renders them, and asking for a maker + strength on a - // blister pack would be noise on the far commoner path. - const [manufacturer, setManufacturer] = useState(""); - const [doseStrength, setDoseStrength] = useState(""); - const [busy, setBusy] = useState(false); - const formId = useId(); - - const parsed = Number(quantity); - const effectiveMode = unitsPerDose > 1 ? quantityMode : "units"; - const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed; - const quantityValid = - Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000; - - async function submit(e: FormEvent) { - e.preventDefault(); - if (!quantityValid || busy) return; - setBusy(true); - try { - await apiPost(`/api/medications/${medicationId}/inventory`, { - // The wire field carries UNITS (v1.16.10 symmetric naming). - unitsTotal: units, - containerType, - printedExpiry: expiry - ? new Date(`${expiry}T00:00:00`).toISOString() - : null, - // Trimmed to null so a field the user opened and left blank stores - // absence rather than an empty string. - manufacturer: manufacturer.trim() || null, - doseStrength: doseStrength.trim() || null, - }); - await invalidateSupplyQueries(queryClient, medicationId); - toast.success(t("medications.detail.bestand.addSuccess")); - onClose(); - } catch { - toast.error(t("medications.detail.bestand.addFailed")); - } finally { - setBusy(false); - } - } - - return ( - !open && onClose()} - title={t("medications.detail.bestand.addTitle")} - description={t("medications.detail.bestand.addDescription")} - footer={ - <> - - - - } - > -
-
- - -
-
- - {unitsPerDose > 1 && ( -
- - -
- )} - setQuantity(e.target.value)} - aria-describedby="inventory-add-quantity-helper" - /> - {unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && ( -

- {effectiveMode === "doses" - ? t("medications.detail.bestand.quantityInUnits", { units }) - : // A unit count below one dose must not read "≈ 0 doses" — - // it is simply less than one dose. - Math.floor(parsed / unitsPerDose) === 0 - ? t("medications.detail.bestand.quantityUnderOneDose") - : t("medications.detail.bestand.quantityInDoses", { - doses: Math.floor(parsed / unitsPerDose), - })} -

- )} -

- {t("medications.detail.bestand.addQuantityHelper")} -

-
-
- - -
- {containerType === "PEN" && ( - <> -
- - setManufacturer(e.target.value)} - /> -
-
- - setDoseStrength(e.target.value)} - /> -

- {t("medications.detail.bestand.addDoseStrengthHint")} -

-
- - )} -
-
- ); -} - -/** - * Correct one container: the remaining-unit count (covers both "I - * miscounted" and a manual withdrawal), the carton-printed expiry, and - * the date it was opened. The server clamps the count to the item's - * capacity and re-derives the state from the two dates; 0 units marks - * the item used up. - * - * Both dates are editable because both decide the state — and the - * opening date in particular is written by the intake consumption hook - * without the user ever typing it, so an auto-open on a back-dated dose - * needs a way back. Each field is sent only when it actually changed, - * so saving a unit correction never rewrites a date the user did not - * touch. - */ -export function AdjustInventoryDialog({ - medicationId, - item, - onClose, -}: { - medicationId: string; - item: InventoryItem; - onClose: () => void; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - // v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty, - // not the literal "null"; the operator types the corrected figure. - const [value, setValue] = useState( - item.unitsRemaining == null ? "" : String(item.unitsRemaining), - ); - const initialExpiry = toLocalDayInput(item.printedExpiry); - const initialOpened = toLocalDayInput(item.firstUseAt); - const [expiry, setExpiry] = useState(initialExpiry); - const [opened, setOpened] = useState(initialOpened); - const [busy, setBusy] = useState(false); - const formId = useId(); - - const parsed = Number(value); - // v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5). - // v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness. - const valid = - value.trim() !== "" && - Number.isFinite(parsed) && - parsed >= 0 && - (item.unitsTotal == null || parsed <= item.unitsTotal); - - async function submit(e: FormEvent) { - e.preventDefault(); - if (!valid || busy) return; - setBusy(true); - try { - await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, { - // The wire field carries UNITS (v1.16.10 symmetric naming). - unitsRemaining: parsed, - // Absent means untouched on the server, so only changed fields - // ride along. A cleared field sends null, which is a deliberate - // "there is no such date" — not the same as leaving it alone. - ...(expiry !== initialExpiry && { - printedExpiry: fromLocalDayInput(expiry), - }), - ...(opened !== initialOpened && { - markAsFirstUseAt: fromLocalDayInput(opened), - }), - }); - await invalidateSupplyQueries(queryClient, medicationId); - toast.success(t("medications.detail.bestand.adjustSuccess")); - onClose(); - } catch { - toast.error(t("medications.detail.bestand.adjustFailed")); - } finally { - setBusy(false); - } - } - - return ( - !open && onClose()} - title={t("medications.detail.bestand.adjustTitle")} - description={t("medications.detail.bestand.adjustDescription")} - footer={ - <> - - - - } - > -
-
- - setValue(e.target.value)} - aria-describedby="inventory-adjust-helper" - /> -

- {t("medications.detail.bestand.adjustHelper", { - total: item.unitsTotal ?? t("medications.detail.bestand.unknown"), - })} -

-
-
- - -

- {t("medications.detail.bestand.editExpiryHelper")} -

-
-
- - -

- {startsInUseClock(item.containerType) - ? t("medications.detail.bestand.editOpenedHelperClock") - : t("medications.detail.bestand.editOpenedHelper")} -

-
-
-
- ); -} - -/** - * Edit the medication-level packaging economics from the supply tab: - * units one dose consumes, and the default container size the register - * flow prefills. Both PUT sparsely onto the medication — the wizard's - * dose step stays the source on create; this is the correction surface - * for a manufacturer switch (same medication, different blister size). - */ -function PackagingDialog({ - medicationId, - unitsPerDose, - dosesPerUnit, - onClose, -}: { - medicationId: string; - unitsPerDose: number; - dosesPerUnit: number | null; - onClose: () => void; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose)); - const [packValue, setPackValue] = useState( - dosesPerUnit === null ? "" : String(dosesPerUnit), - ); - const [busy, setBusy] = useState(false); - const formId = useId(); - - const parsedPerDose = Number(perDoseValue); - const perDoseValid = - Number.isInteger(parsedPerDose) && - parsedPerDose >= 1 && - parsedPerDose <= 100; - const parsedPack = packValue.trim() === "" ? null : Number(packValue); - const packValid = - parsedPack === null || - (Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000); - - async function submit(e: FormEvent) { - e.preventDefault(); - if (!perDoseValid || !packValid || busy) return; - setBusy(true); - try { - await apiPut(`/api/medications/${medicationId}`, { - unitsPerDose: parsedPerDose, - dosesPerUnit: parsedPack, - }); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationDetail(medicationId), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.medications(), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationInventory(medicationId), - }), - ]); - toast.success(t("medications.detail.bestand.packagingSuccess")); - onClose(); - } catch { - toast.error(t("medications.detail.bestand.packagingFailed")); - } finally { - setBusy(false); - } - } - - return ( - !open && onClose()} - title={t("medications.detail.bestand.packagingTitle")} - description={t("medications.detail.bestand.packagingDescription")} - footer={ - <> - - - - } - > -
-
- - setPerDoseValue(e.target.value)} - aria-describedby="packaging-units-per-dose-helper" - /> -

- {t("medications.detail.bestand.packagingUnitsPerDoseHelper")} -

-
-
- - setPackValue(e.target.value)} - aria-describedby="packaging-default-pack-helper" - /> -

- {t("medications.detail.bestand.packagingDefaultPackHelper")} -

-
-
-
- ); -} diff --git a/src/components/medications/sections/inventory-shared.ts b/src/components/medications/sections/inventory-shared.ts new file mode 100644 index 000000000..3233ccea8 --- /dev/null +++ b/src/components/medications/sections/inventory-shared.ts @@ -0,0 +1,63 @@ +/** + * The facts the supply tab's two halves both need. + * + * The tab is a list of containers on one side and three write dialogs on the + * other, and they meet at exactly this much: the row shape the API returns, + * the container kinds it can carry, and the cache keys a write has to drop. + * Keeping that here is what lets the two halves live in their own files + * without either importing the other's rendering. + */ +import type { QueryClient } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; + +/** + * Invalidate every read key whose payload reflects a medication's supply + * after a container write (register / adjust / delete). + * + * The per-medication inventory read (`medicationInventory`) is the supply + * tab's own list. The medications LIST read (`medications`) carries the + * dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that + * the card and table render — without invalidating it, the card kept + * showing the pre-write stock until an unrelated refetch landed (the + * supply-staleness bug). Both keys must drop together. + */ +export async function invalidateSupplyQueries( + queryClient: QueryClient, + medicationId: string, +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.medications(), + }), + ]); +} + +export type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + +export const CONTAINER_TYPES = [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", +] as const; +export type ContainerType = (typeof CONTAINER_TYPES)[number]; + +export interface InventoryItem { + id: string; + state: InventoryState; + containerType: ContainerType; + // v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row); + // the UI renders "—" instead of a fabricated 0. + unitsTotal: number | null; + unitsRemaining: number | null; + /** ISO instant. The carton-printed expiry; null when none was recorded. */ + printedExpiry: string | null; + /** ISO instant. When the container was opened; null while sealed. */ + firstUseAt: string | null; +} From 189024cc00cd46239f1449781d0c26eafa9bde74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:36:24 +0200 Subject: [PATCH 30/39] fix(geo): make GEOLITE2_DIR reachable and tell self-hosters what they can do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime has always read GEOLITE2_DIR, but the variable was absent from the compose `environment:` whitelist, from both env examples, and from the docs. A variable that is not on that whitelist never reaches the container even when the operator's `.env` sets it, so the override existed in code and was unreachable from the documented path. Put it on the whitelist, document it in both examples and the manifest, and add `docs/self-hosting/geolite2.md`: fetch the databases with a free MaxMind account, mount the directory read-only, point the variable at it. The compose file carries the bind-mount line commented out beside the app service. The admin notification told the reader to set MAXMIND_LICENSE_KEY in this repository's Actions secrets — something nobody running the published image can do, about a fallback that sends their sign-in addresses to a third party. It now names the mount, names the online provider it is actually querying, and links the runbook. Six locales. It also no longer claims the carrier column stays empty; the online tier has filled the carrier from the ISP field since v1.25.8. A blank GEOLITE2_DIR now resolves to the default instead of the process working directory: compose substitutes an unset variable to the empty string, which is not nullish. Refs #659 --- .env.example | 3 + .env.production.example | 18 +++ CLAUDE.md | 2 +- docker-compose.yml | 25 +++- docs/README.md | 5 +- docs/self-hosting/geolite2.md | 122 ++++++++++++++++++ messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- scripts/__tests__/check-env.test.ts | 42 ++++++ scripts/env-manifest.json | 11 ++ .../__tests__/geo-offline-detection.test.ts | 72 ++++++++++- src/lib/geo.ts | 44 ++++++- 16 files changed, 341 insertions(+), 27 deletions(-) create mode 100644 docs/self-hosting/geolite2.md diff --git a/.env.example b/.env.example index f3e0196b2..20f090f7b 100644 --- a/.env.example +++ b/.env.example @@ -247,3 +247,6 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # ----------------------------------------------------------------------------- # Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress. # IP_GEO_ALLOW_INSECURE="true" +# +# Directory the offline GeoLite2 databases are read from; default /opt/geolite2 (the path the image bakes its own copy into). Point it at a directory you mount yourself to bring your own MMDBs — see docs/self-hosting/geolite2.md. +# GEOLITE2_DIR="/var/lib/geolite2" diff --git a/.env.production.example b/.env.production.example index 5a8b78bea..76c50df27 100644 --- a/.env.production.example +++ b/.env.production.example @@ -293,6 +293,24 @@ API_TOKEN_HMAC_KEY="" # off the resolver uses only the offline GeoLite2 MMDB tier, if present. # IP_GEO_LOOKUP_DISABLED="1" +# ----------------------------------------------------------------------------- +# Offline GeoLite2 databases -- optional +# ----------------------------------------------------------------------------- +# Where the runtime looks for GeoLite2-City.mmdb and GeoLite2-ASN.mmdb. +# Unset it reads /opt/geolite2, the path the published image bakes its own +# copy into -- but only if the image build had a MaxMind licence key, so on +# a stock `:latest` the directory is usually empty and every login IP goes +# to the online provider instead. +# +# To resolve locations on your own host: create a free MaxMind account, +# pull both databases with `geoipupdate`, mount the directory into the +# container read-only (the commented `volumes:` block in docker-compose.yml +# shows the line), and point this at the container path. Pair it with +# IP_GEO_LOOKUP_DISABLED="1" above for zero IP egress. Refreshing the files +# on the host is enough -- the reader picks them up on the next container +# restart. See docs/self-hosting/geolite2.md. +# GEOLITE2_DIR="/var/lib/geolite2" + # ----------------------------------------------------------------------------- # Environmental-context module (Open-Meteo) -- optional # ----------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index f56bfb753..6fdc9bb20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ These keep tripping operators; they're worth knowing before you push something t - **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression. - **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`. - **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"","v2":""}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key. -- **GeoLite2 databases.** Operators run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build`; the image bakes the MMDBs into `/opt/geolite2/`. Without them the resolver falls back to online `ipwho.is` and the admin login overview surfaces "unknown" for offline lookups. +- **GeoLite2 databases.** Two ways in. Building your own image: run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build` and the MMDBs bake into `/opt/geolite2/`. Running the published image: mount a directory of `.mmdb` files and point `GEOLITE2_DIR` at it — it is on the compose `environment:` whitelist and documented in `docs/self-hosting/geolite2.md`. Without either, the resolver falls back to the online provider and every login IP leaves the host. - **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds. ## iOS handoff diff --git a/docker-compose.yml b/docker-compose.yml index 1987d0311..f07a0b262 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,10 @@ # Leave it unset (or true) only when a TLS reverse proxy serves HTTPS. # # Persistent state lives in exactly one named volume (`pgdata`, Postgres' -# data dir). The app container is stateless — GeoLite2 MMDBs are baked into -# the image — so a backup of the database is a backup of everything. +# data dir). The app container is stateless, so a backup of the database is +# a backup of everything. The optional GeoLite2 databases are either baked +# into the image at build time or mounted read-only from the host and +# pointed at with GEOLITE2_DIR — either way they are replaceable, not state. services: # Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing # self-host deployments stay working with `docker compose up`. @@ -96,6 +98,15 @@ services: IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}" IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}" IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}" + # Directory the offline GeoLite2 databases are read from. Default + # `/opt/geolite2` — the path the published image bakes its own copy + # into, if the build had a MaxMind licence key. Point this at a + # directory you mount yourself (see the `volumes:` block below) to + # bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed + # with `geoipupdate` on your own schedule. Listed here so an operator + # value in .env reaches the container (the `environment:` block is a + # whitelist). See docs/self-hosting/geolite2.md. + GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}" # all = bundle web + worker in this container (default, single-host) # web = serve HTTP only; pair with a separate app-worker service HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}" @@ -219,6 +230,16 @@ services: # Set "true" only if your own iOS build carries Apple's Critical Alerts # entitlement; otherwise urgent alerts use the time-sensitive level. APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}" + # Bring your own GeoLite2 databases. The published image only carries + # MMDBs if the build had a MaxMind licence key, so on `:latest` the + # offline tier is usually absent and every login IP goes to the online + # provider instead. Create a free MaxMind account, pull + # GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the + # directory read-only, and set GEOLITE2_DIR to the container path. + # Steps: docs/self-hosting/geolite2.md. + # + # volumes: + # - ./geolite2:/var/lib/geolite2:ro depends_on: db: condition: service_healthy diff --git a/docs/README.md b/docs/README.md index 296b7b3a5..7d83a7001 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,10 @@ notes, and machine-readable specs that ship alongside the source: off-by-default MCP connector and pointing Claude.ai / ChatGPT / Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md) — OIDC SSO login: IdP setup, the identity-pinning security model, - `OIDC_ONLY` consequences, and the break-glass runbook. + `OIDC_ONLY` consequences, and the break-glass runbook. Also + [`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your + own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending + login IPs to a third-party lookup. - [`migration/`](./migration/) — release-by-release migration notes. Read the entry for the version you're upgrading from. - [`audit/`](./audit/) — per-release audit summaries archived for diff --git a/docs/self-hosting/geolite2.md b/docs/self-hosting/geolite2.md new file mode 100644 index 000000000..de985ecdc --- /dev/null +++ b/docs/self-hosting/geolite2.md @@ -0,0 +1,122 @@ +# Offline geolocation (GeoLite2) + +The admin login overview shows a coarse `City, CC` location and a carrier +next to every sign-in. Two tiers can produce that: + +- **Online.** A third-party lookup, `https://ipwho.is` by default. Free, + keyless, and on by default — which also means every login IP address of + every account on the instance leaves the host. +- **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read + from a local directory. Nothing leaves the host, and the ASN database + is the authoritative source for the carrier. + +The offline tier is optional and absent by default. The published image +only carries the databases when the image build had a MaxMind licence key +(`scripts/fetch-geolite2.sh`, run from the release workflow), which is a +build-time decision you do not control when you pull `:latest`. Bringing +your own copy is the way to get the offline tier on a self-host, and it +is the subject of this page. + +## What you need + +- A free MaxMind account. Sign up at + , then create a licence key + under **My Account → Manage License Keys**. GeoLite2 is free; the + account and key exist because MaxMind requires them for the download. +- `geoipupdate` (MaxMind's own downloader) or any other way to fetch the + two `.mmdb` files onto the host. Most distributions package + `geoipupdate`; there is also an official container image. + +## Setup + +1. Pick a directory on the host and fetch both databases into it. With + `geoipupdate`, `/etc/GeoIP.conf` looks like this: + + ```conf + AccountID + LicenseKey + EditionIDs GeoLite2-City GeoLite2-ASN + DatabaseDirectory /srv/healthlog/geolite2 + ``` + + Then run `geoipupdate`. The directory should end up holding + `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb`. The City database is the + one the readiness check looks for; the ASN database is what fills the + carrier and AS number. + +2. Mount the directory into the app container read-only. In + `docker-compose.yml` the `app` service carries a commented `volumes:` + block for exactly this: + + ```yaml + volumes: + - /srv/healthlog/geolite2:/var/lib/geolite2:ro + ``` + +3. Point the runtime at the container path: + + ```env + GEOLITE2_DIR="/var/lib/geolite2" + ``` + + `GEOLITE2_DIR` is on the compose `environment:` whitelist, so a value + in your `.env` reaches the container. Variables that are not on that + whitelist never do, even when compose substitutes them elsewhere in + the file. + +4. `docker compose up -d` and sign in once. **Admin → System status** + shows `Geo data: Offline (GeoLite2)` when the databases are being + read, and names the online provider when they are not. + +## Turning the online lookup off entirely + +Once the offline tier resolves, the third-party lookup is redundant. To +stop all IP egress: + +```env +IP_GEO_LOOKUP_DISABLED="1" +``` + +The resolver then uses only the local databases and returns nothing when +they miss, rather than asking anyone. An instance handling other people's +health data is a reasonable place to make that trade. + +## Keeping the databases current + +MaxMind reissues GeoLite2 on its own schedule, and a stale city database +is quietly wrong rather than loudly broken — it keeps answering, with +last month's allocations. Run `geoipupdate` on a schedule (a weekly cron +or systemd timer is enough) against the same directory. + +The reader loads each database once per worker process and holds it, so a +refreshed file on the host is picked up at the next container restart, +not immediately. A weekly `geoipupdate` followed by +`docker compose restart app` keeps the two in step. + +## Licence and attribution + +The databases are downloaded under your own MaxMind account, so their +terms are between you and MaxMind — read what your account agrees to at +. HealthLog's own attribution +("this product includes GeoLite2 data created by MaxMind") is already on +the `/about` page and covers an instance that uses them. + +## Troubleshooting + +**System status still says the online provider.** The readiness check +requires `GeoLite2-City.mmdb` to exist in `GEOLITE2_DIR` and no `.empty` +marker file beside it. Check the path from inside the container: +`docker compose exec app ls -l /var/lib/geolite2`. An empty listing means +the bind mount did not land; a `.empty` file means the image was built +without a licence key and you are still reading the baked-in directory +rather than your own. + +**The variable has no effect.** Confirm it actually reached the process: +`docker compose exec app printenv GEOLITE2_DIR`. If it prints nothing, +the value is in `.env` but not on the `environment:` whitelist — compare +against `docker-compose.yml`. + +**The carrier column is empty.** The carrier also comes from the online +provider's ISP field, so it is normally filled even without the ASN +database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no +`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty. diff --git a/messages/de.json b/messages/de.json index 5c3cc6864..77c4745b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Geodaten", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — MAXMIND_LICENSE_KEY in CI setzen", + "offlineGeoFallback": "Online ({host}) — GeoLite2-Datenbanken einbinden und GEOLITE2_DIR setzen", "apiGlobal": "API-Endpunkte und API-Tokens erlauben.", "umamiTitle": "Umami", "umamiDescription": "Web-Analyse mit Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Einnahme überfällig: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.", "offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen", - "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer.", + "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte über die Online-Abfrage bei {host} auf, damit verlässt jede Login-IP diesen Server. Um sie stattdessen lokal aufzulösen, lade die Datenbanken GeoLite2-City und GeoLite2-ASN mit einem kostenlosen MaxMind-Konto herunter, binde das Verzeichnis in den Container ein und setze GEOLITE2_DIR darauf. Anleitung: {docsUrl}", "tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}", "tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md." }, diff --git a/messages/en.json b/messages/en.json index f93b91fdf..e281fa113 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Geo data", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — set MAXMIND_LICENSE_KEY in CI", + "offlineGeoFallback": "Online ({host}) — mount GeoLite2 databases and set GEOLITE2_DIR", "apiGlobal": "Allow API endpoints and API tokens.", "umamiTitle": "Umami", "umamiDescription": "Web analytics with Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dose overdue: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.", "offlineGeoUnavailableTitle": "Offline geolocation databases not loaded", - "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty.", + "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online lookup at {host}, so every login IP address leaves this server. To resolve them locally instead, download the GeoLite2-City and GeoLite2-ASN databases with a free MaxMind account, mount the directory into the container, and point GEOLITE2_DIR at it. Steps: {docsUrl}", "tlsPinChangedTitle": "TLS leaf certificate changed: {host}", "tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md." }, diff --git a/messages/es.json b/messages/es.json index a1248c1fe..8f10194b8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Datos geográficos", "offlineGeoEnabled": "Sin conexión (GeoLite2)", - "offlineGeoFallback": "En línea ({host}) — configura MAXMIND_LICENSE_KEY en CI", + "offlineGeoFallback": "En línea ({host}) — monta las bases GeoLite2 y define GEOLITE2_DIR", "apiGlobal": "Permitir endpoints de API y tokens de API.", "umamiTitle": "Umami", "umamiDescription": "Analítica web con Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dosis atrasada: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.", "offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas", - "offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía.", + "offlineGeoUnavailableBody": "HealthLog resuelve las ubicaciones de inicio de sesión mediante la consulta en línea a {host}, por lo que cada IP de inicio de sesión sale de este servidor. Para resolverlas localmente, descarga las bases GeoLite2-City y GeoLite2-ASN con una cuenta gratuita de MaxMind, monta el directorio en el contenedor y apunta GEOLITE2_DIR a él. Pasos: {docsUrl}", "tlsPinChangedTitle": "Certificado de hoja TLS cambiado: {host}", "tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md." }, diff --git a/messages/fr.json b/messages/fr.json index 79d87c87a..88f747fb6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Données géographiques", "offlineGeoEnabled": "Hors ligne (GeoLite2)", - "offlineGeoFallback": "En ligne ({host}) — définissez MAXMIND_LICENSE_KEY dans la CI", + "offlineGeoFallback": "En ligne ({host}) — montez les bases GeoLite2 et définissez GEOLITE2_DIR", "apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.", "umamiTitle": "Umami", "umamiDescription": "Analyse web avec Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Prise en retard : {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.", "offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées", - "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide.", + "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via la requête en ligne vers {host}, donc chaque adresse IP de connexion quitte ce serveur. Pour les résoudre localement, téléchargez les bases GeoLite2-City et GeoLite2-ASN avec un compte MaxMind gratuit, montez le répertoire dans le conteneur et faites-y pointer GEOLITE2_DIR. Étapes : {docsUrl}", "tlsPinChangedTitle": "Certificat feuille TLS modifié : {host}", "tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md." }, diff --git a/messages/it.json b/messages/it.json index a192e831f..45318df0e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Dati geografici", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — imposta MAXMIND_LICENSE_KEY in CI", + "offlineGeoFallback": "Online ({host}) — monta i database GeoLite2 e imposta GEOLITE2_DIR", "apiGlobal": "Consentire endpoint API e token API.", "umamiTitle": "Umami", "umamiDescription": "Analisi web con Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dose in ritardo: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.", "offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati", - "offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota.", + "offlineGeoUnavailableBody": "HealthLog risolve le posizioni di accesso tramite la ricerca online su {host}, quindi ogni IP di accesso lascia questo server. Per risolverle localmente, scarica i database GeoLite2-City e GeoLite2-ASN con un account MaxMind gratuito, monta la directory nel container e fai puntare GEOLITE2_DIR ad essa. Passaggi: {docsUrl}", "tlsPinChangedTitle": "Certificato foglia TLS modificato: {host}", "tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md." }, diff --git a/messages/pl.json b/messages/pl.json index c7281731c..bed55cf20 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Dane geograficzne", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — ustaw MAXMIND_LICENSE_KEY w CI", + "offlineGeoFallback": "Online ({host}) — podłącz bazy GeoLite2 i ustaw GEOLITE2_DIR", "apiGlobal": "Zezwól na punkty końcowe API i tokeny API.", "umamiTitle": "Umami", "umamiDescription": "Analityka internetowa z Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dawka opóźniona: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.", "offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane", - "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta.", + "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez zapytanie online do {host}, więc każdy adres IP logowania opuszcza ten serwer. Aby rozwiązywać je lokalnie, pobierz bazy GeoLite2-City i GeoLite2-ASN na bezpłatnym koncie MaxMind, podłącz katalog do kontenera i ustaw na niego GEOLITE2_DIR. Kroki: {docsUrl}", "tlsPinChangedTitle": "Zmieniono certyfikat liścia TLS: {host}", "tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md." }, diff --git a/scripts/__tests__/check-env.test.ts b/scripts/__tests__/check-env.test.ts index b97436fb1..5fe355f7c 100644 --- a/scripts/__tests__/check-env.test.ts +++ b/scripts/__tests__/check-env.test.ts @@ -289,4 +289,46 @@ describe("environment inventory", () => { expect(manifestNames).toContain(variableName); } }); + + it("forwards GEOLITE2_DIR through the compose whitelist and documents it", () => { + // issue #659 — `src/lib/geo.ts` has always read this variable, but it + // was absent from the compose `environment:` block, and a variable + // that is not on that whitelist never reaches the container even when + // the operator's `.env` sets it. The override existed in code and was + // unreachable from the documented path. + const repositoryRoot = join(__dirname, "../.."); + const compose = readFileSync( + join(repositoryRoot, "docker-compose.yml"), + "utf8", + ); + const manifest = JSON.parse( + readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"), + ) as { groups: Array<{ variables: Array<{ name: string }> }> }; + + // The compose line must SUBSTITUTE the operator's own value — a + // hardcoded literal would list the key and still ignore `.env`. + expect(compose).toMatch( + /^\s*GEOLITE2_DIR: "\$\{GEOLITE2_DIR(:-[^}]*)?\}"$/m, + ); + // ...and the container needs a place to read the files from. + expect(compose).toMatch(/geolite2:ro$/m); + + for (const exampleFile of [".env.example", ".env.production.example"]) { + const example = readFileSync(join(repositoryRoot, exampleFile), "utf8"); + expect(example, `${exampleFile} does not document GEOLITE2_DIR`).toMatch( + /^# GEOLITE2_DIR=/m, + ); + } + + expect( + manifest.groups.flatMap((group) => + group.variables.map((variable) => variable.name), + ), + ).toContain("GEOLITE2_DIR"); + + // The runbook the admin notification and both examples point at. + expect(() => + readFileSync(join(repositoryRoot, "docs/self-hosting/geolite2.md")), + ).not.toThrow(); + }); }); diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 2ee26a1a1..447d71f49 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -132,6 +132,17 @@ } ] }, + { + "name": "Offline geolocation", + "description": "Optional offline GeoLite2 tier for the admin login overview. Unset, the runtime reads /opt/geolite2 (the path the image bakes its own copy into) and falls back to the online provider when the databases are absent.", + "required": false, + "variables": [ + { + "name": "GEOLITE2_DIR", + "purpose": "Directory holding GeoLite2-City.mmdb + GeoLite2-ASN.mmdb. Point it at a read-only mount to bring your own databases. Default /opt/geolite2." + } + ] + }, { "name": "APNs (iOS push)", "description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.", diff --git a/src/lib/__tests__/geo-offline-detection.test.ts b/src/lib/__tests__/geo-offline-detection.test.ts index f56898822..c12090b59 100644 --- a/src/lib/__tests__/geo-offline-detection.test.ts +++ b/src/lib/__tests__/geo-offline-detection.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -97,6 +97,25 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => { expect(offlineGeoReady()).toBe(false); }); + it("treats a blank GEOLITE2_DIR as unset rather than as the working directory", async () => { + // A compose `${GEOLITE2_DIR:-}` substitutes to the EMPTY STRING, which + // is not nullish. Without the trim, `path.join("", "GeoLite2-City.mmdb")` + // is a RELATIVE path and the readiness check answers from whatever the + // process working directory happens to hold. Proven by putting a stub + // MMDB in the working directory and asserting the blank value still + // resolves to the documented default (which has none). + const originalCwd = process.cwd(); + writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("stub")); + process.chdir(tmpRoot); + try { + process.env.GEOLITE2_DIR = ""; + const { offlineGeoReady } = await import("../geo"); + expect(offlineGeoReady()).toBe(false); + } finally { + process.chdir(originalCwd); + } + }); + it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => { writeFileSync( join(tmpRoot, "GeoLite2-City.mmdb"), @@ -123,15 +142,60 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => { titleKey: "notifications.admin.offlineGeoUnavailableTitle", messageKey: "notifications.admin.offlineGeoUnavailableBody", params: expect.objectContaining({ - secretsUrl: expect.stringContaining( - "github.com/MBombeck/HealthLog/settings/secrets/actions", - ), + host: "ipwho.is", + docsUrl: expect.stringContaining("docs/self-hosting/geolite2.md"), }), metadata: expect.objectContaining({ source: "geo-offline-detection" }), }), ); }); + it("names the real online provider in the notification params", async () => { + writeFileSync(join(tmpRoot, ".empty"), ""); + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + process.env.IP_GEO_LOOKUP_URL = "https://ip-api.example/json"; + const { lookupIpLocation } = await import("../geo"); + + await lookupIpLocation("8.8.8.8"); + await flushMicrotasks(); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ host: "ip-api.example" }), + }), + ); + }); + + it("points the admin at a reachable action, never at a repository secret", async () => { + // issue #659 — the message used to tell a self-hoster to set + // MAXMIND_LICENSE_KEY in this repository's Actions secrets, which is + // not something they can do. Every locale must name the mount instead. + const locales = ["de", "en", "es", "fr", "it", "pl"] as const; + for (const locale of locales) { + const bundle = JSON.parse( + readFileSync( + join(__dirname, `../../../messages/${locale}.json`), + "utf8", + ), + ) as { + notifications: { admin: { offlineGeoUnavailableBody: string } }; + }; + const body = bundle.notifications.admin.offlineGeoUnavailableBody; + expect(body, `${locale} still names a build-time secret`).not.toMatch( + /MAXMIND_LICENSE_KEY|GitHub|Actions|secret/i, + ); + expect(body, `${locale} does not name the reachable override`).toContain( + "GEOLITE2_DIR", + ); + expect(body, `${locale} drops the {host} placeholder`).toContain( + "{host}", + ); + expect(body, `${locale} drops the {docsUrl} placeholder`).toContain( + "{docsUrl}", + ); + } + }); + it("does NOT fire the notification when the City MMDB is present", async () => { writeFileSync( join(tmpRoot, "GeoLite2-City.mmdb"), diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 2896b8d62..81a4591d4 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -31,7 +31,11 @@ * The base path is overridable via `GEOLITE2_DIR` (default * `/opt/geolite2`). When the DB files are missing the helpers silently * skip the offline tier — local dev without the MMDBs still works and - * falls straight back to the online provider. + * falls straight back to the online provider. A self-hoster running the + * published image mounts their own `.mmdb` directory and points the + * variable at it; `docs/self-hosting/geolite2.md` is the runbook and + * `docker-compose.yml` carries the variable on its `environment:` + * whitelist so a value in `.env` actually reaches the container. * * Default provider for the online lookup is ipwho.is (free, no key, HTTPS). * Both the ipwho.is shape (`success`/`country_code`) and the ip-api.com shape @@ -147,8 +151,20 @@ export function resolveGeoProviderHost(): string { } } +/** + * Directory the offline MMDBs are read from. + * + * `GEOLITE2_DIR` is on the compose `environment:` whitelist, so an + * operator who mounts their own databases can point the runtime at them + * without rebuilding the image (`docs/self-hosting/geolite2.md`). Compose + * substitutes an unset variable to the EMPTY STRING rather than leaving + * it absent, and an empty string is not nullish — so trim and treat blank + * as unset, otherwise a stack that merely lists the variable would read + * the process working directory and silently lose the offline tier. + */ function geoLiteDir(): string { - return process.env.GEOLITE2_DIR ?? "/opt/geolite2"; + const configured = process.env.GEOLITE2_DIR?.trim(); + return configured ? configured : "/opt/geolite2"; } // ── Per-IP location cache ──────────────────────────────────────────── @@ -257,6 +273,14 @@ export function __resetGeoLite2CacheForTests(): void { let notifiedThisProcess = false; +/** + * Where the admin notification sends someone who wants the offline tier. + * The in-repo path is the durable one — the runbook ships with the source + * that emits this link, so the two cannot drift apart. + */ +const GEOLITE2_DOCS_URL = + "https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/geolite2.md"; + export function offlineGeoReady(): boolean { try { const dir = geoLiteDir(); @@ -296,9 +320,14 @@ async function notifyOfflineGeoUnavailable(): Promise { userId: admin.id, titleKey: "notifications.admin.offlineGeoUnavailableTitle", messageKey: "notifications.admin.offlineGeoUnavailableBody", + // The message names what the person reading it can actually do. + // It used to point at a licence-key secret in this repository's + // Actions settings, which nobody running the published image can + // set (issue #659) — the reachable path is mounting your own + // databases and pointing GEOLITE2_DIR at them. params: { - secretsUrl: - "https://github.com/MBombeck/HealthLog/settings/secrets/actions", + host: resolveGeoProviderHost(), + docsUrl: GEOLITE2_DOCS_URL, }, metadata: { source: "geo-offline-detection" }, }); @@ -554,9 +583,10 @@ export function lookupIpAsn( if (!ip || PRIVATE_IP.test(ip)) return null; const reader = getAsnReader(); if (!reader) { - // No offline ASN data — alert once per process. There is no online - // fallback for ASN, but the maintainer still wants to know the - // carrier chip is going to stay empty until the secret is set. + // No offline ASN data — alert once per process. The online tier can + // still fill the carrier from its ISP field, but the ASN number and + // the authoritative carrier come from the MMDB, so the admin is told + // once that the offline tier is not being read. if (!offlineGeoReady()) { void notifyOfflineGeoUnavailable(); } From 20824aa798aa7658fb430b07ea7d20f638921907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:39:23 +0200 Subject: [PATCH 31/39] fix(documents): say that turning auto-read on reaches back over the vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch was described three times as applying to what you upload next. A genuine off-to-on flip also enqueues a pass over the documents already stored without a summary, capped at 200 per flip — scanned medical documents going to a third-party provider, from copy that reads as forward-looking only. Drop "newly uploaded" from the sub-label and give the disclosure the sentence it was missing, including the cap. Six locales. The behaviour is right and stays as it is; only the sentence changes. A guard pins the number in the copy to MAX_ENQUEUES_PER_RUN, so moving the cap without touching the copy fails. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- .../document-summary-catchup.test.ts | 37 +++++++++++++++++++ 7 files changed, 49 insertions(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index 77c4745b4..92a3748cf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5098,8 +5098,8 @@ "responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.", "autoRead": { "title": "Dokumente automatisch mit KI lesen", - "subLabel": "Neu hochgeladene Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.", - "honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden – ohne Bestätigung pro Dokument. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen – Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für die von dir hochgeladenen Dokumente akzeptierst.", + "subLabel": "Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.", + "honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden – ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen – Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.", "confirm": "Ich verstehe – automatisches KI-Lesen aktivieren", "cancel": "Abbrechen" }, diff --git a/messages/en.json b/messages/en.json index e281fa113..c109b2117 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5098,8 +5098,8 @@ "responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.", "autoRead": { "title": "Read documents automatically with AI", - "subLabel": "Newly uploaded documents are read and indexed by your configured AI provider without a per-document tap.", - "honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for the documents you upload.", + "subLabel": "Documents are read and indexed by your configured AI provider without a per-document tap.", + "honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.", "confirm": "I understand — turn on automatic AI reading", "cancel": "Cancel" }, diff --git a/messages/es.json b/messages/es.json index 8f10194b8..09e33ddcf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5098,8 +5098,8 @@ "responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.", "autoRead": { "title": "Leer documentos automáticamente con IA", - "subLabel": "Los documentos recién subidos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.", - "honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para los documentos que subes.", + "subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.", + "honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.", "confirm": "Lo entiendo: activar la lectura automática con IA", "cancel": "Cancelar" }, diff --git a/messages/fr.json b/messages/fr.json index 88f747fb6..ae2e08e34 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5098,8 +5098,8 @@ "responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.", "autoRead": { "title": "Lire les documents automatiquement avec l'IA", - "subLabel": "Les documents nouvellement téléversés sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.", - "honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour les documents que vous téléversez.", + "subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.", + "honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.", "confirm": "Je comprends — activer la lecture automatique par l'IA", "cancel": "Annuler" }, diff --git a/messages/it.json b/messages/it.json index 45318df0e..826a7cff7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5098,8 +5098,8 @@ "responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.", "autoRead": { "title": "Leggi i documenti automaticamente con l'IA", - "subLabel": "I documenti appena caricati vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.", - "honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i documenti che carichi.", + "subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.", + "honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.", "confirm": "Ho capito: attiva la lettura automatica con l'IA", "cancel": "Annulla" }, diff --git a/messages/pl.json b/messages/pl.json index bed55cf20..55b270397 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5098,8 +5098,8 @@ "responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.", "autoRead": { "title": "Automatyczne czytanie dokumentów przez AI", - "subLabel": "Nowo przesłane dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.", - "honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla przesyłanych dokumentów.", + "subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.", + "honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane — do 200 przy każdym włączeniu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.", "confirm": "Rozumiem — włącz automatyczne czytanie przez AI", "cancel": "Anuluj" }, diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts index e3666f4d4..4face5127 100644 --- a/src/lib/jobs/__tests__/document-summary-catchup.test.ts +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; /** * The auto-read catch-up pass. @@ -169,3 +171,38 @@ describe("enqueueSummaryCatchUp", () => { }); }); }); + +describe("the toggle copy discloses the catch-up", () => { + const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const; + + function autoReadCopy(locale: string): { subLabel: string; honesty: string } { + const bundle = JSON.parse( + readFileSync( + join(__dirname, `../../../../messages/${locale}.json`), + "utf8", + ), + ) as { + settings: { + ai: { autoRead: { subLabel: string; honesty: string } }; + }; + }; + return bundle.settings.ai.autoRead; + } + + it.each(LOCALES)( + "%s does not promise the switch only applies to future uploads", + (locale) => { + // The flip enqueues a pass over documents ALREADY stored, so copy that + // scopes the switch to newly uploaded documents understates its reach. + const { subLabel } = autoReadCopy(locale); + expect(subLabel).not.toMatch( + /newly|neu hochgeladene|recién subidos|nouvellement téléversés|appena caricati|nowo przesłane/i, + ); + }, + ); + + it.each(LOCALES)("%s names the retroactive pass and its cap", (locale) => { + const { honesty } = autoReadCopy(locale); + expect(honesty).toContain(String(MAX_ENQUEUES_PER_RUN)); + }); +}); From e4a4c996b721567fcf26f73bb5a1bb99050aabf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 13:43:19 +0200 Subject: [PATCH 32/39] fix(documents): stop Backspace deleting a document, and show the key that does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A focused card deleted on Backspace as well as Delete. The timeline's roving tabindex gives a card focus the moment someone tabs into the grid, Backspace is the reflex "go back" key, and a card carries no delete control at all — so the reflex press removed a medical document with nothing but a transient undo toast behind it. Delete alone now deletes. The key resolution moves into a pure helper in vault-utils so the contract is testable without a render harness, and the card stops keeping that contract in a code comment: the overlay button carries aria-keyshortcuts, and a kbd chip appears while the card holds focus. The label is per-locale (Entf / Suppr / Supr / Canc). --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/document-card.test.tsx | 32 +++++++++++++++++++ .../documents/__tests__/vault-utils.test.ts | 20 ++++++++++++ src/components/documents/document-card.tsx | 31 ++++++++++++++++-- src/components/documents/vault-utils.ts | 19 +++++++++++ 10 files changed, 111 insertions(+), 9 deletions(-) diff --git a/messages/de.json b/messages/de.json index 92a3748cf..18d6ef5f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8621,7 +8621,8 @@ "uploading": "Wird hochgeladen…", "dismissFailed": "Fehlgeschlagenen Upload ausblenden", "processing": "Wird verarbeitet…", - "ready": "Bereit" + "ready": "Bereit", + "deleteKey": "Entf" }, "error": { "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", diff --git a/messages/en.json b/messages/en.json index c109b2117..28e7a704f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8621,7 +8621,8 @@ "uploading": "Uploading…", "dismissFailed": "Dismiss failed upload", "processing": "Processing…", - "ready": "Ready" + "ready": "Ready", + "deleteKey": "Del" }, "error": { "fileTooLarge": "This file is larger than the {maxSize} limit.", diff --git a/messages/es.json b/messages/es.json index 09e33ddcf..cecad4c0e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8621,7 +8621,8 @@ "uploading": "Subiendo…", "dismissFailed": "Descartar subida fallida", "processing": "Procesando…", - "ready": "Listo" + "ready": "Listo", + "deleteKey": "Supr" }, "error": { "fileTooLarge": "Este archivo supera el límite de {maxSize}.", diff --git a/messages/fr.json b/messages/fr.json index ae2e08e34..bdce1e8ec 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8621,7 +8621,8 @@ "uploading": "Téléversement…", "dismissFailed": "Ignorer le téléversement échoué", "processing": "Traitement en cours…", - "ready": "Prêt" + "ready": "Prêt", + "deleteKey": "Suppr" }, "error": { "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", diff --git a/messages/it.json b/messages/it.json index 826a7cff7..20ff430fd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8621,7 +8621,8 @@ "uploading": "Caricamento…", "dismissFailed": "Ignora caricamento non riuscito", "processing": "Elaborazione…", - "ready": "Pronto" + "ready": "Pronto", + "deleteKey": "Canc" }, "error": { "fileTooLarge": "Questo file supera il limite di {maxSize}.", diff --git a/messages/pl.json b/messages/pl.json index 55b270397..b0b01785a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8621,7 +8621,8 @@ "uploading": "Przesyłanie…", "dismissFailed": "Odrzuć nieudane przesyłanie", "processing": "Przetwarzanie…", - "ready": "Gotowe" + "ready": "Gotowe", + "deleteKey": "Del" }, "error": { "fileTooLarge": "Ten plik przekracza limit {maxSize}.", diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 5cb9a5e0b..6cece6b9a 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -65,6 +65,38 @@ describe("", () => { expect(html).not.toContain("Download only"); }); + it("announces and shows the Delete shortcut when the card is deletable", () => { + const html = render( + , + ); + // The keyboard contract used to live only in a code comment. It is now + // announced to assistive tech and drawn while the card holds focus. + expect(html).toContain('aria-keyshortcuts="Delete"'); + expect(html).toContain('data-slot="document-delete-hint"'); + expect(html).toContain("Del"); + }); + + it("offers no delete shortcut when the card carries no delete handler", () => { + const html = render( + , + ); + expect(html).not.toContain("aria-keyshortcuts"); + expect(html).not.toContain('data-slot="document-delete-hint"'); + }); + it("shows the download-only badge for attachment-class documents", () => { const html = render( { + it("deletes on Delete and selects on Space", () => { + expect(documentCardKeyAction("Delete")).toBe("delete"); + expect(documentCardKeyAction(" ")).toBe("select"); + }); + + it("does NOT delete on Backspace", () => { + // The reflex "go back" key on a card that takes focus by default, with + // no other delete control and only a transient undo toast behind it. + expect(documentCardKeyAction("Backspace")).toBeNull(); + }); + + it("ignores every other key", () => { + for (const key of ["Enter", "Escape", "a", "ArrowDown", "Del"]) { + expect(documentCardKeyAction(key)).toBeNull(); + } + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index ba9f94763..a1c05ffb1 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -29,6 +29,7 @@ import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; import { + documentCardKeyAction, documentDateKey, formatBytes, isDocumentProcessing, @@ -241,7 +242,15 @@ export function DocumentCard({ (the checkbox stays reachable via its own z-10). Keyboard contract (roving tabindex owned by the timeline): Enter opens (native click), Space toggles selection, Delete removes with undo. Touch: - press-and-hold selects instead of opening. */} + press-and-hold selects instead of opening. + + Backspace is deliberately NOT a delete key here. It is the reflex + "go back" key, the roving tabindex hands a card focus the moment + someone tabs into the grid, and a card carries no other delete + control — so the reflex press destroyed a medical document with + nothing but a transient undo toast to catch it. Delete alone, + announced through `aria-keyshortcuts` and shown as a `` chip + while the card holds focus. */}