From 17dbb225d48a2a16cfab1f8c1db06f61d40c5d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:37:57 +0100 Subject: [PATCH 01/26] fix(profile): read and store the date of birth as a calendar day The iOS app sends the date of birth as the instant its date picker returned, and the profile writers stored that instant as sent. Since the report cover and the share page read the date part of the stored value, an entry made just after midnight in Berlin printed the day before, and a Californian entry made in the evening printed the day after. Both profile writers now store the calendar day the value names in the owner's zone, at noon UTC like other date-only values, so an iOS client reading the instant back in its own zone keeps the date. Older rows keep their instant: dateOfBirthKey reads a date-only anchor (UTC midnight or noon) by its date and any other instant in the owner's zone. The report collector, the targets age, and the web forms that send the date back read it through that helper. --- .../complete/__tests__/route.test.ts | 14 ++++ src/app/api/onboarding/complete/route.ts | 12 ++- src/components/onboarding/baseline-form.tsx | 12 ++- src/components/onboarding/confirm-screen.tsx | 8 +- .../settings/account-section/index.tsx | 4 +- src/lib/auth/__tests__/profile-update.test.ts | 49 +++++++++++ src/lib/auth/profile-update.ts | 19 ++++- src/lib/doctor-report-types.ts | 1 + .../__tests__/leaf-gating-sweep.test.ts | 29 +++++++ src/lib/doctor-report/collect.ts | 4 +- src/lib/targets/build-response.ts | 16 +--- src/lib/tz/__tests__/date-only.test.ts | 83 +++++++++++++++++++ src/lib/tz/date-only.ts | 54 ++++++++++++ 13 files changed, 275 insertions(+), 30 deletions(-) diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts index c33b8a690..1c1f36b18 100644 --- a/src/app/api/onboarding/complete/__tests__/route.test.ts +++ b/src/app/api/onboarding/complete/__tests__/route.test.ts @@ -90,6 +90,20 @@ describe("POST /api/onboarding/complete", () => { ); }); + it("stores a date of birth from the iOS app as its day in the owner's zone", async () => { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: "Europe/Berlin" }, + } as never); + // 14 May picked at 00:30 in Berlin. + const res = await POST(req({ dateOfBirth: "1960-05-13T23:30:00Z" })); + expect(res.status).toBe(200); + const data = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as { + dateOfBirth: Date; + }; + expect(data.dateOfBirth.toISOString()).toBe("1960-05-14T12:00:00.000Z"); + }); + it("still refuses an unsupported gender", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await POST(req({ gender: "diverse" })); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index f51f93d22..42e5c788b 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -29,6 +29,8 @@ import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle" import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { NextRequest } from "next/server"; import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; +import { dateOfBirthForStorage } from "@/lib/tz/date-only"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; /** * Complete the onboarding flow. Saves optional profile data and marks @@ -84,10 +86,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } if (result.data.dateOfBirth) { - const dob = new Date(result.data.dateOfBirth); - if (!isNaN(dob.getTime())) { - data.dateOfBirth = dob; - } + // The calendar day in the owner's zone: the iOS app sends an instant. + const dob = dateOfBirthForStorage( + result.data.dateOfBirth, + await resolveStoredTimezone(user.timezone), + ); + if (dob) data.dateOfBirth = dob; } if (result.data.gender) { diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index b73fdf595..c59b5f886 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -9,6 +9,8 @@ import { type BaselineFieldErrors, } from "@/components/onboarding/baseline-fields"; import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { dateOfBirthKey } from "@/lib/tz/date-only"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { resolveHeightUnitAdapter, type HeightDraft, @@ -59,6 +61,8 @@ interface BaselineInitialValues { heightCm: number | null; dateOfBirth: string | null; gender: string | null; + /** The owner's zone, which a date of birth from the iOS app is read in. */ + timezone?: string; } interface BaselineFormState { @@ -133,8 +137,12 @@ export function BaselineForm({ const [form, setForm] = useState(() => ({ displayName: "", height: heightAdapter.toDraft(initial.heightCm), - // The payload's ISO instant, as the date field's YYYY-MM-DD. - dateOfBirth: initial.dateOfBirth ? initial.dateOfBirth.slice(0, 10) : "", + dateOfBirth: initial.dateOfBirth + ? dateOfBirthKey( + new Date(initial.dateOfBirth), + initial.timezone ?? DEFAULT_TIMEZONE, + ) + : "", gender: initial.gender ?? "", })); const [saving, setSaving] = useState(false); diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 26442c5e9..0de9c2ab1 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -35,6 +35,8 @@ import { questionsToPassBeforeConfirm, } from "@/lib/onboarding/wizard-steps"; +const NO_BASELINE = { heightCm: null, dateOfBirth: null, gender: null }; + /** * v1.39 — the confirm screen. * @@ -334,11 +336,7 @@ export function ConfirmScreen({ ) : ( { ); }); +describe("applyProfileUpdate date of birth", () => { + const writtenDateOfBirth = () => + ( + vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as { + dateOfBirth?: Date | null; + } + ).dateOfBirth; + + it("stores a typed date as that calendar date", async () => { + await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-05-14" }); + expect(writtenDateOfBirth()?.toISOString()).toBe( + "2012-05-14T12:00:00.000Z", + ); + }); + + it("stores an instant from the iOS app as its day in the zone the request sets", async () => { + // 14 May picked at 20:00 in Honolulu (UTC-10). + await applyProfileUpdate(USER_ID, { + dateOfBirth: "2012-05-15T06:00:00Z", + timezone: "Pacific/Honolulu", + }); + expect(writtenDateOfBirth()?.toISOString()).toBe( + "2012-05-14T12:00:00.000Z", + ); + }); + + it("reads an instant in the stored zone when the request sets none", async () => { + // 14 May picked at 09:30 in Nuku'alofa (UTC+13). A user id of its own: + // the zone resolver caches per account. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Pacific/Tongatapu", + } as never); + await applyProfileUpdate("user-tonga", { + dateOfBirth: "2012-05-13T20:30:00Z", + }); + expect(writtenDateOfBirth()?.toISOString()).toBe( + "2012-05-14T12:00:00.000Z", + ); + }); + + it("clears on null and leaves the column alone for a string that names no date", async () => { + await applyProfileUpdate(USER_ID, { dateOfBirth: null }); + expect(writtenDateOfBirth()).toBeNull(); + vi.mocked(prisma.user.update).mockClear(); + await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-02-30" }); + expect(writtenDateOfBirth()).toBeUndefined(); + }); +}); + describe("applyProfileUpdate timezone validation", () => { it("accepts a valid IANA zone", async () => { const result = await applyProfileUpdate(USER_ID, { diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 532fc382e..7762aa62d 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -7,7 +7,9 @@ import { optionalDisplayNameSchema } from "@/lib/validations/display-name"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { profileSchema } from "@/lib/validations/auth"; +import { dateOfBirthForStorage } from "@/lib/tz/date-only"; import { isValidTimezone } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { encrypt } from "@/lib/crypto"; import { invalidateUserMedications, @@ -187,6 +189,21 @@ export interface ApplyProfileError { issues?: SanitisedZodIssue[]; } +/** + * The date of birth to store: the owner's calendar day (the iOS app sends an + * instant, read in the zone this request sets or the stored one), null to + * clear, and undefined, which leaves the column alone, for a string that + * names no date. + */ +async function storedDateOfBirth( + userId: string, + data: Partial, +): Promise { + if (!data.dateOfBirth) return null; + const timezone = data.timezone ?? (await resolveUserTimezone(userId)); + return dateOfBirthForStorage(data.dateOfBirth, timezone) ?? undefined; +} + /** * Validate and persist profile updates for `userId`. Returns either an * `ApplyProfileResult` on success or `ApplyProfileError` with a status @@ -434,7 +451,7 @@ export async function applyProfileUpdate( if (data.email !== undefined) updates.email = normalizedEmail; if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null; if (data.dateOfBirth !== undefined) { - updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null; + updates.dateOfBirth = await storedDateOfBirth(userId, data); } if (data.gender !== undefined) updates.gender = data.gender; if (data.displayName !== undefined) { diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 2b7a73697..e5e2c4026 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -86,6 +86,7 @@ export interface DoctorReportData { * login, never a managed record's generated handle. Null prints no line. */ name: string | null; + /** The calendar date, `YYYY-MM-DD` (`dateOfBirthKey`). */ dateOfBirth: string | null; gender: string | null; heightCm: number | null; diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 6650e83a6..ef2da928b 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -500,6 +500,35 @@ describe("the name the report prints", () => { }); }); +describe("the date of birth the report prints", () => { + const identity = selectionFromLeaves(["PATIENT_IDENTITY"]); + const born = async (dateOfBirth: Date, timezone: string) => { + rows.userUnique = [ + { ...(rows.userUnique[0] as object), dateOfBirth, timezone }, + ]; + const payload = await collectDoctorReportData("u1", RANGE, identity, { + moduleMap: moduleMap(), + }); + return payload.patient.dateOfBirth; + }; + + it("prints a date stored at UTC midnight as that date in every zone", async () => { + const stored = new Date("1960-05-14T00:00:00.000Z"); + expect(await born(stored, "Pacific/Honolulu")).toBe("1960-05-14"); + expect(await born(stored, "Pacific/Tongatapu")).toBe("1960-05-14"); + }); + + it("prints an instant from the iOS app as its date in the owner's zone", async () => { + // 14 May entered at 00:30 in Berlin, and at 20:00 in Honolulu. + expect( + await born(new Date("1960-05-13T23:30:00.000Z"), "Europe/Berlin"), + ).toBe("1960-05-14"); + expect( + await born(new Date("1960-05-15T06:00:00.000Z"), "Pacific/Honolulu"), + ).toBe("1960-05-14"); + }); +}); + describe("fail-closed exclusion", () => { it("excludes a measurement type the catalogue does not know", () => { const gate = resolveReportGate( diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index f55d2f66c..7e91ed12e 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -83,7 +83,7 @@ import { loadSurgicalHistory, loadLabResults, } from "./clinical-records"; -import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { dateOfBirthKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name"; import { readingsMean } from "@/lib/measurements/day-mean"; import { loadScreenerUnvalidatedTranslations } from "./screener-translations"; @@ -723,7 +723,7 @@ export async function collectDoctorReportData( name: identityOn && userProfile ? patientName(userProfile) : null, dateOfBirth: identityOn && userProfile?.dateOfBirth - ? userProfile.dateOfBirth.toISOString() + ? dateOfBirthKey(userProfile.dateOfBirth, reportTz) : null, gender: identityOn ? (userProfile?.gender ?? null) : null, heightCm: identityOn ? (userProfile?.heightCm ?? null) : null, diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index b236b894a..04928a600 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -18,6 +18,7 @@ import { COURSES_COMPLIANCE_SELECT, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; +import { ageInYears } from "@/lib/tz/date-only"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -56,19 +57,6 @@ import { import type { User } from "@/generated/prisma/client"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; -function getAge(dateOfBirth: Date): number { - const today = new Date(); - let age = today.getFullYear() - dateOfBirth.getFullYear(); - const monthDiff = today.getMonth() - dateOfBirth.getMonth(); - if ( - monthDiff < 0 || - (monthDiff === 0 && today.getDate() < dateOfBirth.getDate()) - ) { - age--; - } - return age; -} - /** * The authenticated-user shape the builder reads directly. The profile * fields (height / dob / gender / glucose unit / thresholds) are re-read @@ -256,7 +244,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const now = new Date(); - const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; + const age = ageInYears(dbUser?.dateOfBirth ?? null, timezone, now); const gender = toProfileSex(dbUser?.gender); const heightCm = dbUser?.heightCm ?? null; const latestByType: TargetValueByType = {}; diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts index 3aca0df8c..ad4a85bcd 100644 --- a/src/lib/tz/__tests__/date-only.test.ts +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + ageInYears, + dateOfBirthForStorage, + dateOfBirthKey, isNoonUtcAnchor, dateOnlyAtNoonUtc, dateOnlyKey, @@ -62,6 +65,86 @@ describe("isNoonUtcAnchor", () => { }); }); +describe("date of birth", () => { + // UTC-10 and UTC+13 the year round. + const WEST = "Pacific/Honolulu"; + const EAST = "Pacific/Tongatapu"; + + it("reads a date-only anchor by its date in every zone", () => { + for (const zone of [WEST, EAST, "Europe/Berlin"]) { + expect(dateOfBirthKey(new Date("2012-05-14T00:00:00.000Z"), zone)).toBe( + "2012-05-14", + ); + expect(dateOfBirthKey(new Date("2012-05-14T12:00:00.000Z"), zone)).toBe( + "2012-05-14", + ); + } + }); + + it("reads an instant from the iOS app as its date in the owner's zone", () => { + // 14 May picked at 20:00 in Honolulu, and at 09:30 in Nuku'alofa. + expect(dateOfBirthKey(new Date("2012-05-15T06:00:00.000Z"), WEST)).toBe( + "2012-05-14", + ); + expect(dateOfBirthKey(new Date("2012-05-13T20:30:00.000Z"), EAST)).toBe( + "2012-05-14", + ); + }); + + it("stores a typed date and an iOS instant as the same calendar date", () => { + const noon = "2012-05-14T12:00:00.000Z"; + expect(dateOfBirthForStorage("2012-05-14", WEST)?.toISOString()).toBe(noon); + expect( + dateOfBirthForStorage("2012-05-15T06:00:00.000Z", WEST)?.toISOString(), + ).toBe(noon); + expect( + dateOfBirthForStorage("2012-05-13T20:30:00+00:00", EAST)?.toISOString(), + ).toBe(noon); + }); + + it("keeps a stored value that comes back unchanged", () => { + for (const echoed of [ + "2012-05-14T00:00:00.000Z", + "2012-05-14T12:00:00.000Z", + ]) { + expect(dateOfBirthForStorage(echoed, WEST)?.toISOString()).toBe( + "2012-05-14T12:00:00.000Z", + ); + } + }); + + it("stores nothing for a string that names no date", () => { + expect(dateOfBirthForStorage("2012-02-30", WEST)).toBeNull(); + expect(dateOfBirthForStorage("not a date", WEST)).toBeNull(); + }); + + it("counts the age from the birthday on the owner's calendar", () => { + const iosEntry = new Date("2012-05-13T20:30:00.000Z"); // 14 May in EAST + expect( + ageInYears(iosEntry, EAST, new Date("2026-05-13T10:59:00.000Z")), + ).toBe(13); + // 00:00 on 14 May in Nuku'alofa. + expect( + ageInYears(iosEntry, EAST, new Date("2026-05-13T11:00:00.000Z")), + ).toBe(14); + expect( + ageInYears( + new Date("2012-05-14T00:00:00.000Z"), + WEST, + new Date("2026-05-14T09:59:00.000Z"), + ), + ).toBe(13); + // 00:00 on 14 May in Honolulu. + expect( + ageInYears( + new Date("2012-05-14T00:00:00.000Z"), + WEST, + new Date("2026-05-14T10:00:00.000Z"), + ), + ).toBe(14); + }); +}); + describe("dateOnlyWindow", () => { it("spans UTC midnight to the next UTC midnight, both days included", () => { expect(dateOnlyWindow("2026-11-11")).toEqual({ diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index 4a94de2cd..9edccd10c 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -14,6 +14,8 @@ * migration. */ +import { userDayKey } from "./format"; + const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u; /** True for a strict `YYYY-MM-DD` string. */ @@ -85,6 +87,58 @@ export function dateOnlyKey(value: Date): string { return value.toISOString().slice(0, 10); } +/** + * The calendar date a stored date of birth names, in its owner's `timezone`. + * + * The web and the managed profiles store the date as a date-only anchor (UTC + * midnight, or noon), read by its date. The iOS app sent the instant its date + * picker returned, which names the day it falls on in the owner's zone: a + * Berlin entry of 14 May made at half past midnight is 23:30 UTC on 13 May, + * and read by its UTC date it printed the day before. + */ +export function dateOfBirthKey(value: Date, timezone: string): string { + const anchored = + value.getUTCHours() % 12 === 0 && + value.getUTCMinutes() === 0 && + value.getUTCSeconds() === 0 && + value.getUTCMilliseconds() === 0; + return anchored ? dateOnlyKey(value) : userDayKey(value, timezone); +} + +/** + * A date of birth as a client sends it (`YYYY-MM-DD`, or an instant from the + * iOS app) as it is stored: its calendar date, read the way + * {@link dateOfBirthKey} reads a stored value, at noon UTC. Noon keeps the + * date wherever a client reads the stored instant back in its own zone from + * UTC-11 to UTC+11, where UTC midnight is the evening before everywhere + * west of UTC. Null when the string names no date. + */ +export function dateOfBirthForStorage( + raw: string, + timezone: string, +): Date | null { + if (isDateOnlyKey(raw)) { + return isCalendarDateKey(raw) ? dateOnlyAtNoonUtc(raw) : null; + } + const instant = new Date(raw); + if (Number.isNaN(instant.getTime())) return null; + return dateOnlyAtNoonUtc(dateOfBirthKey(instant, timezone)); +} + +/** Whole years of age on `now` in the owner's `timezone`; null when unknown. */ +export function ageInYears( + dateOfBirth: Date | null, + timezone: string, + now: Date = new Date(), +): number | null { + if (!dateOfBirth) return null; + const [by, bm, bd] = dateOfBirthKey(dateOfBirth, timezone) + .split("-") + .map(Number); + const [ty, tm, td] = userDayKey(now, timezone).split("-").map(Number); + return ty - by - (tm < bm || (tm === bm && td < bd) ? 1 : 0); +} + /** * Calendar date `key` as UTC midnight. A LABEL, never the instant the day * began for anyone: it is how a `@db.Date` column holds a date (Prisma reads From 4be724fefb632706a279de80336d3ac4721c6b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:44:13 +0100 Subject: [PATCH 02/26] fix(ai): keep the picked gateway serving behind an empty chain A person on the OpenAI-compatible gateway lost every AI surface once their chain was saved at all: the fallback to the picked gateway asked whether the chain was the default object itself, so a chain reset to the default, reordered, or written by the gateway form's reasoning setting (which appended a disabled entry to hold the value) no longer counted, and the chain resolved empty. Rows written that way before this release broke on deploy. The fallback now reads the chain's content: the picked gateway serves when nothing else resolves, unless the chain holds the gateway switched off. A disabled gateway entry that carries a reasoning effort is the form's holder, not a switch; the chain editor drops the effort of a gateway it switches off, so a switch-off stays one. Saving the gateway form with the gateway picked writes its entry switched on. Resolution, the presence probe, the account payload and the iOS availability flag all read the same rule. --- .../baselines/source-file-lines.json | 4 +- .../baselines/source-function-lines.json | 44 ++++----- .../provider-chain/__tests__/put.test.ts | 42 ++++++++ src/app/api/insights/provider-chain/route.ts | 28 ++++-- .../user/ai-provider/__tests__/route.test.ts | 18 ++++ src/app/api/user/ai-provider/route.ts | 82 ++++++++++------ .../ai/__tests__/provider-presence.test.ts | 23 ++++- src/lib/ai/__tests__/resolve-chain.test.ts | 95 ++++++++++++------- src/lib/ai/provider-chain.ts | 15 +-- src/lib/ai/provider.ts | 28 +++--- 10 files changed, 261 insertions(+), 118 deletions(-) diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index f9771fe85..e49138989 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,12 +1,12 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 93834, + "total": 93833, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, - "components/insights/coach-panel/chat-bubble.tsx": 822, + "components/insights/coach-panel/chat-bubble.tsx": 821, "components/insights/coach-panel/coach-conversation.tsx": 1278, "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 7b03fc37e..d95e2a6d3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 337, - "total": 109677, + "total": 109571, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -36,7 +36,7 @@ "app/api/nutrients/batch/route.ts :: postBatch": 304, "app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218, "app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262, - "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250, + "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, "app/auth/login/page.tsx :: LoginPage": 569, "app/auth/register/page.tsx :: RegisterPage": 285, @@ -53,20 +53,20 @@ "components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212, "components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341, "components/admin/central-codex-section.tsx :: CentralCodexSection": 228, - "components/admin/encryption-section.tsx :: EncryptionSection": 264, + "components/admin/encryption-section.tsx :: EncryptionSection": 261, "components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220, - "components/admin/glitchtip-section.tsx :: GlitchtipSection": 233, + "components/admin/glitchtip-section.tsx :: GlitchtipSection": 231, "components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232, "components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525, "components/admin/login-overview-section.tsx :: LoginOverviewSection": 503, - "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305, - "components/admin/reminders-section.tsx :: RemindersSection": 309, + "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 302, + "components/admin/reminders-section.tsx :: RemindersSection": 306, "components/admin/user-management-section.tsx :: UserManagementSection": 630, "components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217, "components/auth/claim-profile.tsx :: ClaimProfile": 429, "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, "components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325, - "components/charts/health-chart.tsx :: HealthChart": 470, + "components/charts/health-chart.tsx :: HealthChart": 462, "components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543, "components/charts/mood-chart.tsx :: MoodChart": 896, "components/charts/mood-heatmap.tsx :: MoodHeatmap": 296, @@ -75,7 +75,7 @@ "components/command-palette/command-palette.tsx :: PaletteBody": 333, "components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228, "components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277, - "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214, + "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210, "components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298, "components/cycle/bbt-chart.tsx :: BbtChart": 244, "components/cycle/cycle-calendar.tsx :: CycleCalendar": 292, @@ -99,10 +99,10 @@ "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, - "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419, + "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417, "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, - "components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212, + "components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 208, "components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317, "components/insights/coach-panel/message-thread.tsx :: MessageThread": 390, "components/insights/coach-panel/sources-rail.tsx :: SourcesRail": 203, @@ -147,19 +147,19 @@ "components/measurements/measurement-form.tsx :: MeasurementForm": 496, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767, - "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, + "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 221, "components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266, "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405, "components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287, "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403, - "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208, + "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201, "components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267, "components/medications/medication-card.tsx :: MedicationCard": 413, "components/medications/medication-table.tsx :: MedicationTableRowItem": 377, - "components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214, + "components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 211, "components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219, "components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 254, - "components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296, + "components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 294, "components/medications/sections/composition-section.tsx :: CompositionEditor": 362, "components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238, "components/medications/sections/inventory-section.tsx :: InventorySection": 368, @@ -167,12 +167,12 @@ "components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516, "components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222, "components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230, - "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256, - "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270, + "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 252, + "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 261, "components/mood/mood-form.tsx :: MoodForm": 514, "components/mood/mood-list.tsx :: MoodList": 1109, "components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257, - "components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255, + "components/onboarding/confirm-screen.tsx :: ConfirmScreen": 251, "components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423, "components/onboarding/tour.tsx :: OnboardingTour": 544, "components/practitioners/practitioner-list.tsx :: PractitionerList": 225, @@ -186,8 +186,8 @@ "components/settings/access/managed-profile-edit-form.tsx :: ManagedProfileEditFields": 208, "components/settings/access/managed-profile-guardians.tsx :: ManagedProfileGuardians": 234, "components/settings/account-section/index.tsx :: AccountSection": 659, - "components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280, - "components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228, + "components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 270, + "components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 224, "components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 284, "components/settings/api-section.tsx :: ApiTokensCard": 269, "components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251, @@ -195,7 +195,7 @@ "components/settings/environment-section.tsx :: EnvironmentSection": 666, "components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298, "components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212, - "components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225, + "components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 222, "components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293, "components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458, "components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255, @@ -210,7 +210,7 @@ "components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370, "components/settings/score-section.tsx :: ScoreSection": 383, "components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 395, - "components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288, + "components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 286, "components/settings/security-section/totp-card.tsx :: TotpCard": 533, "components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245, "components/settings/settings-shell.tsx :: SettingsShell": 254, @@ -222,7 +222,7 @@ "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 220, - "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474, + "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473, "components/timeline/life-event-form.tsx :: LifeEventForm": 281, "components/timeline/timeline-chart.tsx :: ChartBody": 236, "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, diff --git a/src/app/api/insights/provider-chain/__tests__/put.test.ts b/src/app/api/insights/provider-chain/__tests__/put.test.ts index 3c7a666e5..f58ce7b78 100644 --- a/src/app/api/insights/provider-chain/__tests__/put.test.ts +++ b/src/app/api/insights/provider-chain/__tests__/put.test.ts @@ -238,6 +238,48 @@ describe("PUT /api/insights/provider-chain", () => { ]); }); + it("drops the effort of a gateway the person switches off", async () => { + // A disabled gateway entry with an effort is the gateway form's holder, + // which the picked gateway still serves behind; a switch-off is not. + storedChain([ + { + providerType: "openai-compatible", + priority: 1, + enabled: true, + reasoningEffort: "low", + }, + ]); + await put([{ providerType: "openai-compatible", enabled: false }]); + expect(persisted()).toEqual([ + { providerType: "openai-compatible", priority: 1, enabled: false }, + ]); + }); + + it("keeps the holder's effort when the chain is saved around it", async () => { + storedChain([ + { providerType: "local", priority: 1, enabled: true }, + { + providerType: "openai-compatible", + priority: 2, + enabled: false, + reasoningEffort: "low", + }, + ]); + await put([ + { providerType: "openai-compatible", enabled: false }, + { providerType: "local", enabled: true }, + ]); + expect(persisted()).toEqual([ + { + providerType: "openai-compatible", + priority: 1, + enabled: false, + reasoningEffort: "low", + }, + { providerType: "local", priority: 2, enabled: true }, + ]); + }); + it("clears it on null", async () => { storedChain([ { diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 4685916f1..3a2e15e00 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -118,18 +118,28 @@ export const PUT = apiHandler(async (request: NextRequest) => { where: { id: user.id }, select: { aiProviderChain: true }, }); + const storedChain = parseProviderChain(stored?.aiProviderChain ?? null); const storedEffort = new Map( - parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [ - e.providerType, - e.reasoningEffort ?? null, - ]), + storedChain.map((e) => [e.providerType, e.reasoningEffort ?? null]), ); + // A disabled gateway entry with an effort is the holder the gateway form + // writes, which the picked gateway still serves behind + // (`servesPickedGateway`). Switching the gateway off here is the person's + // word, so its effort goes with the switch. + const gatewayWasOn = + storedChain.find((e) => e.providerType === "openai-compatible")?.enabled !== + false; const normalised = parsed.data.chain.map((entry, idx) => { - const reasoningEffort = isReasoningProviderType(entry.providerType) - ? entry.reasoningEffort === undefined - ? (storedEffort.get(entry.providerType) ?? null) - : entry.reasoningEffort - : null; + const switchedOff = + entry.providerType === "openai-compatible" && + !entry.enabled && + gatewayWasOn; + const reasoningEffort = + !isReasoningProviderType(entry.providerType) || switchedOff + ? null + : entry.reasoningEffort === undefined + ? (storedEffort.get(entry.providerType) ?? null) + : entry.reasoningEffort; return { providerType: entry.providerType, priority: idx + 1, 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 481a4791f..1e0ac20f8 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -751,6 +751,24 @@ describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => { ]); }); + it("switches the gateway's entry on when the gateway form saves it as the pick", async () => { + // The disabled holder above, written for a picked gateway, read as the + // person switching off the provider they had just picked. + storedChain(null); + await patch( + patchRequest({ + provider: "OPENAI_COMPATIBLE", + compatReasoningEffort: "low", + }), + ); + const chain = written()?.aiProviderChain as Array<{ + providerType: string; + }>; + expect( + chain.find((e) => e.providerType === "openai-compatible"), + ).toMatchObject({ enabled: true, reasoningEffort: "low" }); + }); + it("does not touch the chain when Default is restated on a default chain", async () => { storedChain(null); await patch( diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 09a99b994..97e5a951b 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -16,6 +16,7 @@ import { providerWorkAuthorityForRecord, } from "@/lib/sharing/provider-work-authority"; import { annotate } from "@/lib/logging/context"; +import type { z } from "zod/v4"; import { aiProviderPatchSchema } from "@/lib/validations/ai-provider"; import { chainWithReasoningEffort, @@ -191,6 +192,51 @@ export const GET = apiHandler(async () => { }); }); +/** + * #1126 — the chain to write after the Local or gateway form set its entry's + * reasoning effort, or undefined when nothing changed. Stored on the entry in + * `aiProviderChain` rather than in a column of their own, so the setting is + * per entry and a chain can mix a thinking model with one that does not know + * the field. See `chainWithReasoningEffort` for how a default chain, or a + * provider absent from the chain, holds the value. + */ +async function chainAfterReasoningSave( + userId: string, + body: Pick< + z.infer, + "localReasoningEffort" | "compatReasoningEffort" + >, + pickedNow: unknown, +): Promise { + const current = await prisma.user.findUnique({ + where: { id: userId }, + select: { aiProviderChain: true, aiProvider: true }, + }); + let chain: unknown = current?.aiProviderChain ?? null; + if (body.localReasoningEffort !== undefined) { + chain = chainWithReasoningEffort(chain, "local", body.localReasoningEffort); + } + if (body.compatReasoningEffort !== undefined) { + // Saving the gateway form picks the gateway, so its entry is on: a + // disabled one read as the person switching off what they had just picked. + const picked = pickedNow !== undefined ? pickedNow : current?.aiProvider; + chain = chainWithReasoningEffort( + chain, + "openai-compatible", + body.compatReasoningEffort, + String(picked ?? "").toUpperCase() === "OPENAI_COMPATIBLE", + ); + } + // Written only when it changed: restating Default on a chain that was + // never customised must not freeze today's default chain into the row. + const before = serializeProviderChain( + parseProviderChain(current?.aiProviderChain ?? null), + ); + return serializeProviderChain(parseProviderChain(chain)) !== before + ? chain + : undefined; +} + export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.update" } }); @@ -337,42 +383,16 @@ export const PATCH = apiHandler(async (request: NextRequest) => { updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds; } - // ── #1126 — reasoning settings of the Local and gateway entries ── - // Stored on the entry in `aiProviderChain` rather than in a column of their - // own, so the setting is per entry and a chain can mix a thinking model with - // one that does not know the field. See `chainWithReasoningEffort` for how a - // default chain, or a provider absent from the chain, holds the value. const reasoningTouched = body.localReasoningEffort !== undefined || body.compatReasoningEffort !== undefined; if (reasoningTouched) { - const current = await prisma.user.findUnique({ - where: { id: user.id }, - select: { aiProviderChain: true }, - }); - let chain: unknown = current?.aiProviderChain ?? null; - if (body.localReasoningEffort !== undefined) { - chain = chainWithReasoningEffort( - chain, - "local", - body.localReasoningEffort, - ); - } - if (body.compatReasoningEffort !== undefined) { - chain = chainWithReasoningEffort( - chain, - "openai-compatible", - body.compatReasoningEffort, - ); - } - // Written only when it changed: restating Default on a chain that was - // never customised must not freeze today's default chain into the row. - const before = serializeProviderChain( - parseProviderChain(current?.aiProviderChain ?? null), + const chain = await chainAfterReasoningSave( + user.id, + body, + updates.aiProvider, ); - if (serializeProviderChain(parseProviderChain(chain)) !== before) { - updates.aiProviderChain = chain; - } + if (chain !== undefined) updates.aiProviderChain = chain; } if (Object.keys(updates).length === 0 && !reasoningTouched) { diff --git a/src/lib/ai/__tests__/provider-presence.test.ts b/src/lib/ai/__tests__/provider-presence.test.ts index 87b19c213..56c62d561 100644 --- a/src/lib/ai/__tests__/provider-presence.test.ts +++ b/src/lib/ai/__tests__/provider-presence.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/crypto", () => ({ })); import { prisma } from "@/lib/db"; +import { chainWithReasoningEffort } from "../provider-chain"; import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority"; import { @@ -238,16 +239,36 @@ describe("probeProviderChain", () => { ]); expect(presence.managedBy).toBe("user"); - // Once the person saved a chain without it, the chain is their word. + // A saved chain without it says nothing about the gateway, and neither + // does the switched-off entry the gateway form wrote for its reasoning + // effort. + for (const aiProviderChain of [ + [{ providerType: "local", enabled: true, priority: 1 }], + chainWithReasoningEffort(null, "openai-compatible", "low"), + ]) { + load(row({ ...gateway, aiProviderChain })); + expect( + (await probeProviderChain("u1", OWNER)).entries.map( + (e) => e.providerType, + ), + ).toEqual(["openai-compatible"]); + } + + // Switched off in the chain, the gateway stays off. load( row({ ...gateway, aiProviderChain: [ { providerType: "local", enabled: true, priority: 1 }, + { providerType: "openai-compatible", enabled: false, priority: 2 }, ], }), ); expect((await probeProviderChain("u1", OWNER)).entries).toEqual([]); + expect(await resolveProviderAvailability("u1")).toEqual({ + aiAvailable: false, + managedBy: null, + }); }); it("carries the person's in-browser OCR opt-in", async () => { diff --git a/src/lib/ai/__tests__/resolve-chain.test.ts b/src/lib/ai/__tests__/resolve-chain.test.ts index 1b7fcf67c..183d72c71 100644 --- a/src/lib/ai/__tests__/resolve-chain.test.ts +++ b/src/lib/ai/__tests__/resolve-chain.test.ts @@ -17,6 +17,10 @@ vi.mock("@/lib/ai/codex-oauth", () => ({ })); import { resolveProviderChain } from "../provider"; +import { + chainWithReasoningEffort, + PROVIDER_CHAIN_DEFAULT, +} from "../provider-chain"; import { prisma } from "@/lib/db"; import { decryptCodexCreds } from "@/lib/ai/codex-oauth"; @@ -207,41 +211,66 @@ describe("resolveProviderChain", () => { expect(await resolveProviderChain("user-1")).toEqual([]); }); +}); - it("serves the picked gateway, under its own name, on a chain never saved", async () => { - const gatewayRow = { - aiProvider: "OPENAI_COMPATIBLE", - aiModel: null, - aiBaseUrl: null, - aiAnthropicKeyEncrypted: null, - aiLocalKeyEncrypted: null, - aiOpenaiKeyEncrypted: null, - aiCompatBaseUrl: "https://gateway.example/v1", - aiCompatKeyEncrypted: null, - aiCompatModel: "llama-3", - role: "USER", - }; - userRows( - { ...gatewayRow, aiProviderChain: null }, - { - codexAccessTokenEncrypted: null, - codexRefreshTokenEncrypted: null, - codexTokenExpiresAt: null, - codexConnectionStatus: "disconnected", - }, - ); - operator({ - adminAiKeyEncrypted: null, - }); +describe("resolveProviderChain and the picked gateway", () => { + const gatewayRow = { + aiProvider: "OPENAI_COMPATIBLE", + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiCompatBaseUrl: "https://gateway.example/v1", + aiCompatKeyEncrypted: null, + aiCompatModel: "llama-3", + role: "USER", + }; + const DEFAULT_SAVED = PROVIDER_CHAIN_DEFAULT.map((entry) => ({ ...entry })); - const chain = await resolveProviderChain("user-1"); - expect(chain.map((c) => c.providerType)).toEqual(["openai-compatible"]); + /** Serve `chain` as the record's saved chain; Codex is not connected. */ + async function resolveWith(chain: unknown) { + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + select?: Record; + }) => + args.select?.codexAccessTokenEncrypted + ? { + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + codexTokenExpiresAt: null, + codexConnectionStatus: "disconnected", + } + : { ...gatewayRow, aiProviderChain: chain }) as never); + operator({ adminAiKeyEncrypted: null }); + return (await resolveProviderChain("user-1")).map((c) => c.providerType); + } - // A saved chain without the gateway is the person's word. - userRows({ - ...gatewayRow, - aiProviderChain: [{ providerType: "local", priority: 1, enabled: true }], - }); - expect(await resolveProviderChain("user-1")).toEqual([]); + it.each([ + ["never saved", null], + ["saved with the default content", DEFAULT_SAVED], + [ + "reset to the default and saved", + JSON.parse(JSON.stringify(DEFAULT_SAVED)), + ], + ["reordered without the gateway", [...DEFAULT_SAVED].reverse()], + [ + "holding a reasoning effort the gateway form wrote switched off", + chainWithReasoningEffort(null, "openai-compatible", "low"), + ], + [ + "holding a reasoning effort the gateway form writes now", + chainWithReasoningEffort(null, "openai-compatible", "low", true), + ], + ])("serves the gateway on a chain %s", async (_case, chain) => { + expect(await resolveWith(chain)).toEqual(["openai-compatible"]); + }); + + it("serves nothing once the person switched the gateway off in the chain", async () => { + expect( + await resolveWith([ + ...DEFAULT_SAVED, + { providerType: "openai-compatible", priority: 6, enabled: false }, + ]), + ).toEqual([]); }); }); diff --git a/src/lib/ai/provider-chain.ts b/src/lib/ai/provider-chain.ts index 9cefda920..09fb634f5 100644 --- a/src/lib/ai/provider-chain.ts +++ b/src/lib/ai/provider-chain.ts @@ -181,27 +181,30 @@ export function serializeProviderChain( * reasoning effort from that provider's own settings form. * * The setting lives on the chain entry, so a chain that was never customised - * is materialised from the default first. A provider that is not in the chain - * at all gets a DISABLED entry to hold the value: the setting must reach the - * provider when it is resolved as the selected one, and a disabled entry is - * inert everywhere else (every reader filters on `enabled`). Clearing on an - * absent entry writes nothing new. + * is materialised from the default first. With `enable`, which the gateway + * form passes because saving it picks the gateway, the entry is switched on, + * a new one included. Otherwise a provider that is not in the chain gets a + * DISABLED entry to hold the value, inert everywhere but where the provider + * serves as the picked one (`servesPickedGateway`). Clearing on an absent + * entry writes nothing new. */ export function chainWithReasoningEffort( rawChain: unknown, providerType: ReasoningProviderType, effort: ReasoningEffort | null, + enable = false, ): ProviderChainEntry[] { const chain = parseProviderChain(rawChain).map((e) => ({ ...e })); const entry = chain.find((e) => e.providerType === providerType); if (entry) { if (effort) entry.reasoningEffort = effort; else delete entry.reasoningEffort; + if (enable) entry.enabled = true; } else if (effort) { chain.push({ providerType, priority: Math.max(0, ...chain.map((e) => e.priority)) + 1, - enabled: false, + enabled: enable, reasoningEffort: effort, }); } diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 3e3d60cd8..45d600f9a 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -25,7 +25,6 @@ import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "./local-host-allowlist"; import { parseProviderChain, - PROVIDER_CHAIN_DEFAULT, PROVIDER_CHAIN_TYPES, type ProviderChainType, } from "./provider-chain"; @@ -812,7 +811,7 @@ async function readBoundSettings(userId: string): Promise<{ * usable credential (e.g. chain says `anthropic` but * `aiAnthropicKeyEncrypted` is null). * 3. Append the operator's central Codex behind the person's opt-in. - * 4. Still empty: the picked gateway on a chain never saved + * 4. Still empty: the picked gateway, unless the chain switched it off * (`servesPickedGateway`), else nothing. Every caller treats an * empty result as "no provider"; none falls back on its own. */ @@ -964,24 +963,25 @@ function providerOrigin(providerType: string): ProviderManagedBy { } /** - * What a chain that resolves empty falls back to. A saved chain is the - * person's own word: an entry they switched off or removed stays out, so an - * empty saved chain means no provider. A chain they never saved is the - * default one (`parseProviderChain` hands back `PROVIDER_CHAIN_DEFAULT` - * itself), which holds every provider a person can pick except the - * OpenAI-compatible gateway; so the gateway they picked under Settings, AI - * serves when nothing in the default chain does. Resolution and the presence - * probe both read this, so they cannot disagree about it. + * What a chain that resolves empty falls back to: the OpenAI-compatible + * gateway picked under Settings, AI, unless the chain switched it off. The + * default chain has no entry for the gateway, so a chain never saved, reset + * to the default, or reordered without it says nothing about the gateway. + * Nor does a disabled gateway entry that carries a reasoning effort: that is + * the holder the gateway form wrote for the setting + * (`chainWithReasoningEffort`), not a switch somebody turned off; the chain + * editor drops the effort of a gateway it switches off. Resolution and the + * presence probe both read this, so they cannot disagree about it. */ function servesPickedGateway(row: { aiProvider: string | null; aiProviderChain: unknown; }): boolean { - return ( - parseProviderChain(row.aiProviderChain ?? null) === - PROVIDER_CHAIN_DEFAULT && - row.aiProvider?.toUpperCase() === "OPENAI_COMPATIBLE" + if (row.aiProvider?.toUpperCase() !== "OPENAI_COMPATIBLE") return false; + const gateway = parseProviderChain(row.aiProviderChain ?? null).find( + (entry) => entry.providerType === "openai-compatible", ); + return !gateway || gateway.enabled || gateway.reasoningEffort !== undefined; } /** From 0c77eaa08544bd445953ba6b0be27bbce56b37a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:49:20 +0100 Subject: [PATCH 03/26] fix(errors): name the refusals behind the AI test and a passkey proof Three refusals answered without an error code, so the web printed the status's generic sentence. A connection test with nothing configured read "Some of the entries were not accepted", and a mistyped password while adding a passkey read "Your session has ended. Sign in again", which sent people to sign out for nothing. An empty chain now resolves to no provider, which the test route answers with `ai.provider.none`. The passkey registration and the step-up mint answer a failed or stale proof with `auth.reproof.failed`. `ai.provider.none`, `ai.budget.exceeded` and `auth.reproof.too_weak` get sentences in every locale. --- messages/de.json | 11 ++++++++++- messages/en.json | 11 ++++++++++- messages/es.json | 11 ++++++++++- messages/fr.json | 11 ++++++++++- messages/it.json | 11 ++++++++++- messages/ko.json | 11 ++++++++++- messages/nl.json | 11 ++++++++++- messages/pl.json | 11 ++++++++++- messages/pt-BR.json | 11 ++++++++++- messages/ru.json | 11 ++++++++++- .../api/auth/passkey/register-options/route.ts | 7 ++++++- .../api/auth/passkey/register-verify/route.ts | 16 +++++++++------- src/app/api/auth/step-up/route.ts | 5 ++++- src/lib/ai/__tests__/provider.test.ts | 15 +++++++++++++++ src/lib/ai/provider.ts | 4 ++-- .../enrollment-proof-second-factor.test.ts | 14 ++++++++++++++ 16 files changed, 150 insertions(+), 21 deletions(-) diff --git a/messages/de.json b/messages/de.json index 13bcb27aa..c96aea84d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Bestätige dein aktuelles Passwort, um das zu ändern.", - "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert." + "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert.", + "too_weak": "Bestätige mit deinem zweiten Faktor oder einem Passkey. Bei diesem Konto reicht das Passwort allein nicht." }, "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Die Trainingsdaten sind zu groß, um sie auf einmal zu speichern.", "source_not_permitted": "Diese Verbindung darf keine Trainings unter dieser Quelle speichern." } + }, + "ai": { + "provider": { + "none": "Es ist kein KI-Anbieter eingerichtet, daher wurde nichts gesendet. Richte einen in den KI-Einstellungen ein." + }, + "budget": { + "exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit." + } } }, "doctorReport": { diff --git a/messages/en.json b/messages/en.json index 6ba260ae3..cf62fc3bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirm your current password to change this.", - "failed": "That did not verify. Nothing was changed." + "failed": "That did not verify. Nothing was changed.", + "too_weak": "Confirm with your second factor or a passkey. On this account a password alone is not enough." }, "missing": "Your session has ended, so nothing was changed. Sign in again to continue.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "The workout data is too large to save in one go.", "source_not_permitted": "This connection may not save workouts under that source." } + }, + "ai": { + "provider": { + "none": "No AI provider is set up, so nothing was sent to one. Set one up in the AI settings." + }, + "budget": { + "exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow." + } } }, "doctorReport": { diff --git a/messages/es.json b/messages/es.json index 62128c707..fd4716bf1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirma tu contraseña actual para cambiar esto.", - "failed": "La verificación ha fallado. No se ha cambiado nada." + "failed": "La verificación ha fallado. No se ha cambiado nada.", + "too_weak": "Confírmalo con tu segundo factor o una clave de acceso. En esta cuenta no basta con la contraseña." }, "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Los datos del entrenamiento son demasiado grandes para guardarlos de una vez.", "source_not_permitted": "Esta conexión no puede guardar entrenamientos con esa fuente." } + }, + "ai": { + "provider": { + "none": "No hay ningún proveedor de IA configurado, así que no se ha enviado nada. Configura uno en los ajustes de IA." + }, + "budget": { + "exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana." + } } }, "doctorReport": { diff --git a/messages/fr.json b/messages/fr.json index f9885e3c5..7c3d96f50 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirmez votre mot de passe actuel pour modifier ceci.", - "failed": "La vérification a échoué. Rien n'a été modifié." + "failed": "La vérification a échoué. Rien n'a été modifié.", + "too_weak": "Confirmez avec votre second facteur ou une clé d’accès. Sur ce compte, le mot de passe seul ne suffit pas." }, "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Les données d'entraînement sont trop volumineuses pour être enregistrées en une fois.", "source_not_permitted": "Cette connexion n'a pas le droit d'enregistrer des entraînements sous cette source." } + }, + "ai": { + "provider": { + "none": "Aucun fournisseur d’IA n’est configuré, rien n’a donc été envoyé. Configurez-en un dans les réglages IA." + }, + "budget": { + "exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain." + } } }, "doctorReport": { diff --git a/messages/it.json b/messages/it.json index f78033c05..fa0fca028 100644 --- a/messages/it.json +++ b/messages/it.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Conferma la password attuale per modificarlo.", - "failed": "La verifica non è riuscita. Non è stato modificato nulla." + "failed": "La verifica non è riuscita. Non è stato modificato nulla.", + "too_weak": "Conferma con il secondo fattore o una passkey. Per questo account la sola password non basta." }, "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "I dati dell'allenamento sono troppo grandi per salvarli in una volta.", "source_not_permitted": "Questa connessione non può salvare allenamenti con questa fonte." } + }, + "ai": { + "provider": { + "none": "Nessun provider di IA è configurato, quindi non è stato inviato nulla. Configurane uno nelle impostazioni IA." + }, + "budget": { + "exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani." + } } }, "doctorReport": { diff --git a/messages/ko.json b/messages/ko.json index c62750df2..8d8f43157 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "변경하려면 현재 비밀번호를 확인하세요.", - "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다." + "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다.", + "too_weak": "2단계 인증 수단이나 패스키로 확인하세요. 이 계정에서는 비밀번호만으로는 충분하지 않아요." }, "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "운동 데이터가 너무 커서 한 번에 저장할 수 없어요.", "source_not_permitted": "이 연결로는 해당 출처의 운동을 저장할 수 없어요." } + }, + "ai": { + "provider": { + "none": "설정된 AI 제공자가 없어 아무것도 보내지 않았어요. AI 설정에서 하나를 설정하세요." + }, + "budget": { + "exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요." + } } }, "doctorReport": { diff --git a/messages/nl.json b/messages/nl.json index da1aa2e27..2997c7662 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Bevestig je huidige wachtwoord om dit te wijzigen.", - "failed": "De verificatie is mislukt. Er is niets gewijzigd." + "failed": "De verificatie is mislukt. Er is niets gewijzigd.", + "too_weak": "Bevestig met je tweede factor of een passkey. Voor dit account is alleen je wachtwoord niet genoeg." }, "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "De trainingsgegevens zijn te groot om in één keer op te slaan.", "source_not_permitted": "Deze koppeling mag geen trainingen onder die bron opslaan." } + }, + "ai": { + "provider": { + "none": "Er is geen AI-provider ingesteld, dus er is niets verzonden. Stel er een in bij de AI-instellingen." + }, + "budget": { + "exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer." + } } }, "doctorReport": { diff --git a/messages/pl.json b/messages/pl.json index 854b541fe..d76523f14 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Potwierdź obecne hasło, aby to zmienić.", - "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione." + "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione.", + "too_weak": "Potwierdź drugim składnikiem lub kluczem dostępu. Na tym koncie samo hasło nie wystarczy." }, "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Dane treningu są za duże, by zapisać je za jednym razem.", "source_not_permitted": "To połączenie nie może zapisywać treningów pod tym źródłem." } + }, + "ai": { + "provider": { + "none": "Nie skonfigurowano dostawcy AI, więc nic nie zostało wysłane. Skonfiguruj go w ustawieniach AI." + }, + "budget": { + "exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro." + } } }, "doctorReport": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index bf5265f6e..aefc823cc 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirme sua senha atual para alterar isto.", - "failed": "A verificação não foi concluída. Nada foi alterado." + "failed": "A verificação não foi concluída. Nada foi alterado.", + "too_weak": "Confirme com seu segundo fator ou uma chave de acesso. Nesta conta, só a senha não basta." }, "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Os dados do treino são grandes demais para salvar de uma vez.", "source_not_permitted": "Esta conexão não pode salvar treinos com essa origem." } + }, + "ai": { + "provider": { + "none": "Nenhum provedor de IA está configurado, então nada foi enviado. Configure um nas configurações de IA." + }, + "budget": { + "exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã." + } } }, "doctorReport": { diff --git a/messages/ru.json b/messages/ru.json index d207f54f6..a63019d8f 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Подтвердите текущий пароль, чтобы изменить это.", - "failed": "Не подтверждено. Ничего не изменено." + "failed": "Не подтверждено. Ничего не изменено.", + "too_weak": "Подтвердите вторым фактором или ключом доступа. Для этого аккаунта одного пароля недостаточно." }, "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Данные тренировки слишком велики, чтобы сохранить их за один раз.", "source_not_permitted": "Это подключение не может сохранять тренировки с таким источником." } + }, + "ai": { + "provider": { + "none": "Поставщик ИИ не настроен, поэтому ничего не отправлено. Настройте его в настройках ИИ." + }, + "budget": { + "exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра." + } } }, "doctorReport": { diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index 968d9948a..e725a37bf 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -6,6 +6,7 @@ import { import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; import { RECENT_PROOF_MAX_AGE_MS, + REPROOF_FAILED_CODE, REPROOF_REQUIRED_CODE, isSecondFactorProof, recordReproofFailure, @@ -29,8 +30,12 @@ import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { stepUpMintSchema } from "@/lib/validations/step-up"; +// A wrong or missing proof, not an ended session: the code keeps the web from +// reading the bare 401 as "sign in again". const proofRequired = () => - apiError("Fresh existing-factor proof required", 401); + apiError("Fresh existing-factor proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }); export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index e50fc106c..62ba82f4f 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -13,6 +13,7 @@ import { import { NextRequest } from "next/server"; import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { REPROOF_FAILED_CODE } from "@/lib/auth/existing-factor-proof"; export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); @@ -40,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => { (freshSession.mfaVerifiedAt && freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000) ) { - return apiError("Fresh reauthentication proof required", 401); + return apiError("Fresh reauthentication proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }); } const claim = await consumeRegistrationChallenge({ @@ -49,12 +52,11 @@ export const POST = apiHandler(async (request: NextRequest) => { sessionId: session.id, }); if (!claim.ok) { - return apiError( - claim.reason === "wrong_context" - ? "Fresh reauthentication proof required" - : "Registration challenge invalid or expired", - claim.reason === "wrong_context" ? 401 : 400, - ); + return claim.reason === "wrong_context" + ? apiError("Fresh reauthentication proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }) + : apiError("Registration challenge invalid or expired", 400); } let verification; diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 6592d81c2..e0ee2c1ea 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -40,6 +40,7 @@ import { rateLimitHeaders, } from "@/lib/rate-limit"; import { + REPROOF_FAILED_CODE, REPROOF_LIMIT, REPROOF_WINDOW_MS, reproofBucket, @@ -132,7 +133,9 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "auth.stepup.mint.failed" }, meta: { method: parsed.data.method, reason: failure }, }); - return apiError("Verification failed", 401); + return apiError("Verification failed", 401, { + errorCode: REPROOF_FAILED_CODE, + }); } const { token, expiresAt } = await mintStepUpElevation({ diff --git a/src/lib/ai/__tests__/provider.test.ts b/src/lib/ai/__tests__/provider.test.ts index a0490dd41..46a0ad60e 100644 --- a/src/lib/ai/__tests__/provider.test.ts +++ b/src/lib/ai/__tests__/provider.test.ts @@ -352,6 +352,21 @@ describe("resolveProviderForTest — empty override resolves via the chain", () expect(provider.type).not.toBe("none"); expect(provider).toBeInstanceOf(OpenAIClient); }); + + it("answers no provider, which the route names, when the chain resolves empty", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiProviderChain: [{ providerType: "openai", priority: 1, enabled: true }], + } as never); + + const provider = await resolveProviderForTest("user-123", {}); + expect(provider.type).toBe("none"); + }); }); describe("resolveProviderAvailability", () => { diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 45d600f9a..d51551682 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -1359,10 +1359,10 @@ async function resolveProviderForTestUnbound( // fallback used to probe a different provider than generation ran // (observed: test→admin-key while generation→codex), so a green test gave // no signal about whether overnight generation would work. + // Nothing resolving is the route's `ai.provider.none`, not a refused entry. if (!provider) { const chain = await resolveProviderChain(userId); - if (chain.length > 0) return chain[0].instance; - throw new AITestConfigError(422, "No AI provider configured"); + return chain.length > 0 ? chain[0].instance : new NoProvider(); } switch (provider) { diff --git a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts index a6dac826c..20ca150e3 100644 --- a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts +++ b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts @@ -247,6 +247,20 @@ describe("POST /api/auth/passkey/register-options", () => { expect(res.status).toBe(200); expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1"); }); + + it("names a mistyped password as a failed proof, not an ended session", async () => { + vi.mocked(requireCookieAuth).mockResolvedValue({ + user: stored(PLAIN), + session: { id: "sess-1" }, + } as never); + vi.mocked(verifyPassword).mockResolvedValue(false); + const res = await PASSKEY_REGISTER_OPTIONS( + passkeyReq({ method: "password", password: "typo" }) as never, + ); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.failed"); + expect(createRegistrationOptions).not.toHaveBeenCalled(); + }); }); describe("changing the email address", () => { From 9683daf4f2681b22f4b209c5689554e66bc2ecdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:50:42 +0100 Subject: [PATCH 04/26] fix(api): take a new idempotency key once the server has answered A create form kept its key until a save succeeded. The server stores a refusal under the key for a day, so after a 422 or 409 the corrected retry ran as a different body without any protection, and a timeout on it could store the entry twice; an unchanged retry after the cause was gone replayed the stale refusal until the form was closed. Any answer now settles the key. Only a timeout, a dropped connection, or the 409 that says the first request under the key is still running (`X-Idempotent-Replay: false`, now `ApiError.inFlight`) keeps it, so those retries still replay the first answer. --- .../__tests__/use-idempotent-post.test.tsx | 47 +++++++++++++++++++ src/lib/api/api-fetch.ts | 9 ++++ src/lib/api/use-idempotent-post.ts | 25 ++++++---- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/lib/api/__tests__/use-idempotent-post.test.tsx b/src/lib/api/__tests__/use-idempotent-post.test.tsx index cd307c480..f025086c4 100644 --- a/src/lib/api/__tests__/use-idempotent-post.test.tsx +++ b/src/lib/api/__tests__/use-idempotent-post.test.tsx @@ -63,6 +63,53 @@ describe("useIdempotentPost", () => { expect(keyOf(1)).not.toBe(keyOf(0)); }); + it("takes a new key once the server refused the entry", async () => { + // The refusal is stored under the key, so the corrected retry under it + // ran unprotected and an unchanged one replayed the refusal. + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: null, error: "Name taken", meta: {} }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce(stored()); + const post = openForm(); + + await expect( + post("/api/custom-metrics", { name: "Steps" }), + ).rejects.toThrow(); + await post("/api/custom-metrics", { name: "Steps" }); + + expect(keyOf(1)).not.toBe(keyOf(0)); + }); + + it("keeps the key while the first request is still running", async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: null, + error: "A request with this Idempotency-Key is already in progress", + }), + { + status: 409, + headers: { + "Content-Type": "application/json", + "X-Idempotent-Replay": "false", + }, + }, + ), + ) + .mockResolvedValueOnce(stored()); + const post = openForm(); + + await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow(); + await post("/api/mood-entries", { mood: "GOOD" }); + + expect(keyOf(1)).toBe(keyOf(0)); + }); + it("keeps a header the caller set", async () => { fetchMock.mockResolvedValueOnce(stored()); const post = openForm(); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 7d180aaea..5e23d76c2 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -79,6 +79,12 @@ export class ApiError extends Error { * so a form can mark the one the server refused. Empty when it named none. */ readonly issuePaths: readonly string[]; + /** + * The idempotency layer's "a request under this key is still running" (a + * 409 with `X-Idempotent-Replay: false`): nothing was decided, and a retry + * under the same key replays the first request's answer. + */ + readonly inFlight: boolean; constructor( message: string, @@ -86,12 +92,14 @@ export class ApiError extends Error { meta?: Record, requestId?: string | null, issuePaths: readonly string[] = [], + inFlight = false, ) { super(message); this.name = "ApiError"; this.status = status; this.meta = meta; this.issuePaths = issuePaths; + this.inFlight = inFlight; const fromMeta = typeof meta?.requestId === "string" && meta.requestId.length > 0 ? meta.requestId @@ -133,6 +141,7 @@ export async function throwApiError(res: Response): Promise { meta, res.headers.get("x-request-id"), issuePaths, + res.status === 409 && res.headers.get("x-idempotent-replay") === "false", ); // A server-side failure is worth an id in the session's support info, // whether or not the call site ends up showing it. diff --git a/src/lib/api/use-idempotent-post.ts b/src/lib/api/use-idempotent-post.ts index 4bd1ac40a..28aae8453 100644 --- a/src/lib/api/use-idempotent-post.ts +++ b/src/lib/api/use-idempotent-post.ts @@ -2,19 +2,23 @@ import { useRef } from "react"; -import { apiPost } from "@/lib/api/api-fetch"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { randomId } from "@/lib/random-id"; /** - * `apiPost` for a create form, under one `Idempotency-Key` until a save lands. + * `apiPost` for a create form, under one `Idempotency-Key` until an answer + * arrives. * * The transport mints a fresh key per call, which covers a replayed request * but not a second tap on Save: when the first answer never arrived (the 15 s * timeout on a weak signal) the entry is already stored, and the retry is a * new call with a new key, so the server stored it twice. Every submit - * through this function sends the same key until one succeeds, so the retry - * replays the first answer instead; a retry with a changed body is a - * different request and runs as one. The next entry gets a new key. + * through this function sends the same key until the server answers, so the + * retry replays the first answer instead. An answer settles the key, a + * refusal included: the server keeps a refusal under its key for a day, so a + * corrected retry under the old key was never protected and an unchanged one + * replayed a refusal that no longer held. Only the answer that the first + * request is still running keeps it. The next entry gets a new key. */ export function useIdempotentPost() { const key = useRef(null); @@ -26,8 +30,13 @@ export function useIdempotentPost() { key.current ??= randomId(); const headers = new Headers(init?.headers); headers.set("Idempotency-Key", key.current); - const result = await apiPost(path, body, { ...init, headers }); - key.current = null; - return result; + try { + const result = await apiPost(path, body, { ...init, headers }); + key.current = null; + return result; + } catch (error) { + if (error instanceof ApiError && !error.inFlight) key.current = null; + throw error; + } }; } From ce01d455be094c3160955ec2d9e241d17dec685b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:53:31 +0100 Subject: [PATCH 05/26] fix(targets): no adult weight and BMI bands on a child's record Now that the BMI chart draws inside a managed record, a child's normal BMI sat in the red under-17 band, and the weight page painted the adult WHO band (about 26 to 35 kg for 118 cm), so a healthy 21 kg read as underweight. The release's own weight-band test pinned that band. No band here knows a child's age yet. For a record under 18, or a managed record with no date of birth, the targets read names no WHO weight range, no BMI range and no category; the record's own weight target still shows, and the values and the chart stay. The BMI chart and the weight band draw only what the server names. The weight-band test now uses an adult height and gains the child case. The targets builder and the vitals builder grow by two lines each for the flag; their size baselines are raised to match. --- .../baselines/source-function-lines.json | 4 +- src/app/insights/bmi/__tests__/page.test.tsx | 23 ++++++++- src/app/insights/bmi/page.tsx | 16 +++++-- .../weight/__tests__/weight-band.test.ts | 17 +++++-- src/app/insights/weight/weight-band.ts | 10 ++-- src/lib/targets/__tests__/builders.test.ts | 47 +++++++++++++++++++ src/lib/targets/build-response.ts | 2 + src/lib/targets/vitals-builder.ts | 17 +++++-- 8 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d95e2a6d3..a8b1b6796 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -331,8 +331,8 @@ "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261, "lib/strava/sync.ts :: runStravaSync": 219, "lib/support/report.ts :: collectSupportReportFacts": 332, - "lib/targets/build-response.ts :: buildTargetsResponse": 330, - "lib/targets/vitals-builder.ts :: buildVitalTargets": 324, + "lib/targets/build-response.ts :: buildTargetsResponse": 332, + "lib/targets/vitals-builder.ts :: buildVitalTargets": 326, "lib/telegram-webhook-handlers.ts :: handleCallback": 582, "lib/telegram-webhook-handlers.ts :: handleTextMessage": 322, "lib/withings/sync-activity.ts :: syncUserActivity": 377, diff --git a/src/app/insights/bmi/__tests__/page.test.tsx b/src/app/insights/bmi/__tests__/page.test.tsx index b0f342bc4..9c65f7420 100644 --- a/src/app/insights/bmi/__tests__/page.test.tsx +++ b/src/app/insights/bmi/__tests__/page.test.tsx @@ -23,6 +23,7 @@ const state = { viewerHeight: null as number | null, inSharedRecord: true, recordHeight: 118 as number | null, + bmiRange: { min: 18.5, max: 24.9 } as { min: number; max: number } | null, }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ @@ -41,7 +42,10 @@ vi.mock("@tanstack/react-query", () => ({ data: enabled === false ? undefined - : { profile: { heightCm: state.recordHeight } }, + : { + targets: [{ type: "BMI", range: state.bmiRange }], + profile: { heightCm: state.recordHeight }, + }, }), })); @@ -61,6 +65,7 @@ describe("/insights/bmi height gate", () => { state.viewerHeight = null; state.inSharedRecord = true; state.recordHeight = 118; + state.bmiRange = { min: 18.5, max: 24.9 }; }); it("reads the record's height inside somebody else's record", () => { @@ -77,4 +82,20 @@ describe("/insights/bmi height gate", () => { state.viewerHeight = null; expect(asksForHeight()).toBe(true); }); + + it("draws the adult bands only where the record's BMI range is named", () => { + const chartBands = () => { + renderToStaticMarkup(); + const { children } = shellProps.mock.calls.at(-1)![0] as { + children: Array<{ props?: { valueBands?: unknown } }>; + }; + return children.find( + (child) => child?.props && "valueBands" in child.props, + )?.props?.valueBands; + }; + expect(chartBands()).toBeDefined(); + // A child's record: the server names no BMI range. + state.bmiRange = null; + expect(chartBands()).toBeUndefined(); + }); }); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index cc6db7390..04dc43c69 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -56,15 +56,21 @@ export default function InsightsBmiPage() { const { data: targets } = useQuery({ queryKey: queryKeys.insightsTargets(), queryFn: () => - apiGet<{ profile?: { heightCm: number | null } }>( - "/api/insights/targets", - ), - enabled: isAuthenticated && inSharedRecord, + apiGet<{ + targets: ReadonlyArray<{ type: string; range: unknown }>; + profile?: { heightCm: number | null }; + }>("/api/insights/targets"), + enabled: isAuthenticated, }); const heightCm = inSharedRecord ? (targets?.profile?.heightCm ?? null) : (user?.heightCm ?? null); const heightKnown = !inSharedRecord || targets !== undefined; + // The adult bands only where the server names the BMI range: it names none + // for a child's record, which they do not describe. + const bmiBands = targets?.targets.find((entry) => entry.type === "BMI")?.range + ? BMI_BANDS + : undefined; // v1.4.27 — BMI is derived from WEIGHT. When no weight readings // exist yet, the existing "set your height" branch can never compute @@ -140,7 +146,7 @@ export default function InsightsBmiPage() { colors={["var(--chart-5)"]} unit="kg/m²" valueMode="bmi" - valueBands={BMI_BANDS} + valueBands={bmiBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} showDataTable diff --git a/src/app/insights/weight/__tests__/weight-band.test.ts b/src/app/insights/weight/__tests__/weight-band.test.ts index 0a4da57a5..a716b6a27 100644 --- a/src/app/insights/weight/__tests__/weight-band.test.ts +++ b/src/app/insights/weight/__tests__/weight-band.test.ts @@ -12,11 +12,22 @@ describe("selectWeightBandRange", () => { it("builds the WHO band from the record's height", () => { const range = selectWeightBandRange({ targets: [ - { type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" }, + { type: "WEIGHT", range: { min: 59.9, max: 81 }, source: "WHO BMI" }, ], - profile: { heightCm: 118 }, + profile: { heightCm: 180 }, }); - expect(range).toEqual(buildWeightRangeFromHeight(118)); + expect(range).toEqual(buildWeightRangeFromHeight(180)); + }); + + it("paints no adult band behind a child's weights", () => { + // A 118 cm child: the server names no WHO range for a record under 18, + // where the adult band read a normal 21 kg as underweight. + expect( + selectWeightBandRange({ + targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }], + profile: { heightCm: 118 }, + }), + ).toBeNull(); }); it("paints the record's own target with the override wings", () => { diff --git a/src/app/insights/weight/weight-band.ts b/src/app/insights/weight/weight-band.ts index 0c645f8c6..2f0756a0d 100644 --- a/src/app/insights/weight/weight-band.ts +++ b/src/app/insights/weight/weight-band.ts @@ -24,14 +24,16 @@ export interface WeightTargetsSlice { * record's own target and height, and the reference panel under the chart * reads the same payload. The record's own target (`source: "Custom"`) gets * the wings `getEffectiveRange` gives every override; otherwise the band is - * the height-derived WHO one, and with neither there is none. + * the height-derived WHO one. Where the server names no range there is none: + * no target and no height, or a child's record, which the adult WHO band does + * not describe. */ export function selectWeightBandRange( data: WeightTargetsSlice | null, ): TrafficRange | null { - if (!data) return null; - const weight = data.targets.find((entry) => entry.type === "WEIGHT"); - if (weight?.source === "Custom" && weight.range) { + const weight = data?.targets.find((entry) => entry.type === "WEIGHT"); + if (!data || !weight?.range) return null; + if (weight.source === "Custom") { return getEffectiveRange( "WEIGHT", { heightCm: null, dateOfBirth: null, gender: null }, diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index 9a765e531..9c7a2fcef 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -274,3 +274,50 @@ describe("buildVitalTargets — user weight target", () => { expect(weight?.source).toBe("Custom"); }); }); + +describe("buildVitalTargets — a child's record", () => { + // 118 cm and 21 kg: a BMI of about 15, normal for a six-year-old and red + // under the adult cut-offs. + const child = { + recentMeasurements: [], + restingPulseProxy: [], + latestByType: { WEIGHT: 21 }, + average30ByType: { WEIGHT: 21 }, + heightCm: 118, + age: 6, + gender: null, + timezone: TZ, + now: NOW, + weightTargetOverride: null, + adultBands: false, + }; + const card = ( + targets: ReturnType["targets"], + type: string, + ) => targets.find((t) => t.type === type); + + it("keeps the values and names no adult band or category", () => { + const { targets } = buildVitalTargets(child); + expect(card(targets, "WEIGHT")).toMatchObject({ + current: 21, + range: null, + classification: null, + }); + expect(card(targets, "BMI")).toMatchObject({ + current: 15.1, + range: null, + classification: null, + }); + }); + + it("keeps the record's own weight target", () => { + const { targets } = buildVitalTargets({ + ...child, + weightTargetOverride: { min: 19, max: 24 }, + }); + expect(card(targets, "WEIGHT")).toMatchObject({ + range: { min: 19, max: 24 }, + source: "Custom", + }); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 04928a600..f7f1a2022 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -116,6 +116,7 @@ export async function buildTargetsResponse(user: AuthedUser) { select: { heightCm: true, dateOfBirth: true, + managedProfileAt: true, gender: true, glucoseUnit: true, hasDiabetes: true, @@ -274,6 +275,7 @@ export async function buildTargetsResponse(user: AuthedUser) { average30ByType, heightCm, age, + adultBands: age != null ? age >= 18 : dbUser?.managedProfileAt == null, gender, timezone, now, diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index f792643b4..9bed9428a 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -74,6 +74,13 @@ interface VitalTargetsInput { * names a category, not a goal. Absent or `null` keeps the age band. */ bpTargetOverride?: BloodPressureTargetRange | null; + /** + * Whether the adult WHO weight and BMI cut-offs apply. No band here knows a + * child's age yet, so for a record under 18, or a managed one with no date + * of birth, the weight card keeps only the record's own target, the BMI + * card names no band, and neither names a category. Absent means they do. + */ + adultBands?: boolean; } export interface VitalTargetsResult { @@ -170,6 +177,7 @@ export function buildVitalTargets({ now, weightTargetOverride, bpTargetOverride = null, + adultBands = true, }: VitalTargetsInput): VitalTargetsResult { const targets: TargetItem[] = []; const consistencyClock = { timezone, now }; @@ -181,10 +189,11 @@ export function buildVitalTargets({ // v1.34 — a user-set target outranks the WHO BMI band. The BMI // classification below stays WHO-derived either way: it names which BMI // category a reading falls in, which is a reference fact and not a goal. + const whoHeightCm = adultBands ? heightCm : null; const weightRange = - weightTargetOverride ?? (heightCm ? getWeightRange(heightCm) : null); + weightTargetOverride ?? (whoHeightCm ? getWeightRange(whoHeightCm) : null); let weightClassification: TargetItem["classification"] = null; - if (latestByType.WEIGHT != null && heightCm) { + if (latestByType.WEIGHT != null && whoHeightCm) { const heightM = heightCm / 100; const classification = classifyBMI( latestByType.WEIGHT / (heightM * heightM), @@ -389,14 +398,14 @@ export function buildVitalTargets({ ? Math.round((averageWeight / heightSquared) * 10) / 10 : null; let bmiClassification: TargetItem["classification"] = null; - if (currentBmi != null) { + if (currentBmi != null && adultBands) { const classification = classifyBMI(currentBmi); bmiClassification = { category: classification.category, color: classification.color, }; } - const bmiRange = { min: 18.5, max: 24.9 }; + const bmiRange = adultBands ? { min: 18.5, max: 24.9 } : null; targets.push({ type: "BMI", label: "BMI", From f76e5cdafd6dac6ec6931fb46c75a307eae3468f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:54:34 +0100 Subject: [PATCH 06/26] fix(insights): let a read grant read the record's targets The weight band and the BMI chart read `/api/insights/targets` inside a shared record, and the route asked for a manage grant, so a read or write delegate got a 403 and saw no band and no BMI. The targets are the bands the dashboard snapshot already paints on a whole-record read grant, computed with no provider, so the read now asks for the same level. A section-scoped grant is still refused. --- src/__tests__/sharing-surface-guard.test.ts | 9 ++------- src/app/api/insights/targets/route.ts | 7 ++++--- src/lib/openapi/routes/insights/paths.ts | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4c5c12a33..fd016cf02 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1553,7 +1553,7 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/insights/targets/route.ts": { domain: "record", - why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + why: "The record's resolved targets: the bands and reference ranges the dashboard snapshot already paints on the whole-record grant, read the same way, with no provider on the path. GET only; nothing is written here.", }, "app/api/insights/ecg/route.ts": { domain: "record", @@ -2099,11 +2099,6 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.", }, - "app/api/insights/targets/route.ts": { - domain: "record", - conditions: [], - why: "The record's resolved targets.", - }, "app/api/insights/ecg/route.ts": { domain: "record", conditions: [], @@ -2305,7 +2300,7 @@ const ACTOR_ROUTES: Record = { * make, or a managed profile's guardian's, never an adult record's delegate's. * 276 -> 278. */ -const FROZEN_ENTRY_COUNT = 278; +const FROZEN_ENTRY_COUNT = 277; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 315f0a691..6d5077e60 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -15,9 +15,10 @@ import { buildTargetsResponse } from "@/lib/targets/build-response"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - // v1.37.0 — MANAGE-level read: computed over the whole record, with no - // provider anywhere on the path. - const { user } = await requireRecordAuth("manage", "record"); + // A read of the whole record, with no provider anywhere on the path, at the + // level the dashboard snapshot answers: the weight band and the BMI chart + // read it inside a shared record, and a read or write grant got a 403. + const { user } = await requireRecordAuth("read", "record"); // v1.16.8 — stale-while-revalidate read. The cold build is the multi-query // walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body // serves immediately while ONE background rebuild warms the cell. Writes diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index d66169878..5e8e9bbeb 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -541,7 +541,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Per-metric target tiles + consistency strips", description: - "The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth.", + "The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at READ level over the whole record, like the dashboard snapshot: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth.", responses: { ...recordRefusal(), "200": { From 46906916d816abfc5330a8ade482a3fab3306d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:55:24 +0100 Subject: [PATCH 07/26] fix(vaccinations): re-anchor a booster on the logged dose's own day Logging the next dose satisfies a matching booster reminder with the dose date, a calendar date stored at UTC midnight. The reminder engine reads that instant in the person's zone, where west of UTC it is the evening before, so the next booster came due a day early. The first booster's anchor was already counted from the start of the dose day in the person's zone; the satisfy path now hands the engine the same instant. --- .../__tests__/satisfy-matching.test.ts | 62 +++++++++++++++++++ src/lib/vaccinations/satisfy-matching.ts | 8 ++- .../vaccination-booster-mint.test.ts | 7 ++- .../vaccination-booster-satisfy.test.ts | 7 ++- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/lib/vaccinations/__tests__/satisfy-matching.test.ts diff --git a/src/lib/vaccinations/__tests__/satisfy-matching.test.ts b/src/lib/vaccinations/__tests__/satisfy-matching.test.ts new file mode 100644 index 000000000..6c241c304 --- /dev/null +++ b/src/lib/vaccinations/__tests__/satisfy-matching.test.ts @@ -0,0 +1,62 @@ +/** + * Logging the next dose re-anchors a booster reminder on the dose date. The + * dose date is a calendar date stored at UTC midnight, which is the evening + * before west of UTC, so the next booster came due a day early there. + */ +import { describe, expect, it, vi } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +const TZ = "America/New_York"; + +function fakeTx() { + const updates: Array> = []; + const reminder = { + id: "reminder-1", + userId: "u1", + vaccinationAntigen: "tetanus", + intervalDays: 3650, + rrule: null, + anchorDate: new Date("2016-03-10T05:00:00.000Z"), + notifyHour: 9, + nextDueAt: new Date("2026-03-07T14:00:00.000Z"), + lastSatisfiedAt: null, + lastSkippedAt: null, + createdAt: new Date("2016-03-10T05:00:00.000Z"), + endsOn: null, + }; + const tx = { + measurementReminder: { + findMany: vi.fn(async () => [reminder]), + updateMany: vi.fn(async ({ data }: { data: Record }) => { + updates.push(data); + return { count: 1 }; + }), + }, + measurementReminderEvent: { create: vi.fn(async () => ({})) }, + }; + return { tx: tx as unknown as Prisma.TransactionClient, updates }; +} + +describe("satisfyBoostersForDose", () => { + it("counts the next interval from the dose's own date west of UTC", async () => { + const { tx, updates } = fakeTx(); + await satisfyBoostersForDose( + tx, + "u1", + { antigenSlug: "tetanus" }, + new Date("2026-03-10T00:00:00.000Z"), + TZ, + ); + // Ten years of days after 10 March, on the person's calendar. + expect(userDayKey(updates[0].nextDueAt as Date, TZ)).toBe( + shiftDateKey("2026-03-10", 3650), + ); + expect(updates[0].lastSatisfiedAt).toEqual( + startOfLocalDayKey("2026-03-10", TZ), + ); + }); +}); diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts index 4febaa950..ee12ec64e 100644 --- a/src/lib/vaccinations/satisfy-matching.ts +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -52,6 +52,8 @@ import { satisfyReminder, type SatisfiableReminder, } from "@/lib/measurement-reminders/satisfy"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { componentsForDose, loadCustomVaccineLookup, @@ -136,6 +138,10 @@ export async function satisfyBoostersForDose( return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id); }); + // The dose date is a calendar date at UTC midnight, the evening before west + // of UTC; the reminder engine reads an instant in the person's zone, so it + // gets the start of that day there, as the booster mint does. + const satisfiedAt = startOfLocalDayKey(dateOnlyKey(occurredAt), timezone); const satisfiedReminderIds: string[] = []; for (const reminder of ordered) { const row: SatisfiableReminder = { @@ -160,7 +166,7 @@ export async function satisfyBoostersForDose( tx as unknown as PrismaClient, row, timezone, - occurredAt, + satisfiedAt, "vaccination", ); if (result.satisfied) satisfiedReminderIds.push(reminder.id); diff --git a/tests/integration/vaccination-booster-mint.test.ts b/tests/integration/vaccination-booster-mint.test.ts index 3386bd2aa..14184ad47 100644 --- a/tests/integration/vaccination-booster-mint.test.ts +++ b/tests/integration/vaccination-booster-mint.test.ts @@ -18,6 +18,8 @@ process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; import { POST as createVaccination } from "@/app/api/vaccinations/route"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { POST as mintBooster } from "@/app/api/vaccinations/[id]/booster/route"; import { GET as listReminders } from "@/app/api/measurement-reminders/route"; @@ -221,7 +223,10 @@ describe("the whole activity-aware loop, through real routes", () => { const after = await prisma.measurementReminder.findUniqueOrThrow({ where: { id: reminderId }, }); - expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString()); + // The start of the dose's calendar day in the person's zone. + expect(after.lastSatisfiedAt).toEqual( + startOfLocalDayKey(dateOnlyKey(occurredAt), "Europe/Berlin"), + ); expect(after.nextDueAt!.getTime()).toBeGreaterThan( before.nextDueAt!.getTime(), ); diff --git a/tests/integration/vaccination-booster-satisfy.test.ts b/tests/integration/vaccination-booster-satisfy.test.ts index f6d1db2a3..489286d86 100644 --- a/tests/integration/vaccination-booster-satisfy.test.ts +++ b/tests/integration/vaccination-booster-satisfy.test.ts @@ -24,6 +24,8 @@ process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; import { POST as createVaccination } from "@/app/api/vaccinations/route"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -132,7 +134,10 @@ describe("a dose satisfies the boosters it answers", () => { expect(res.status, JSON.stringify(await res.clone().json())).toBe(201); const after = await reminderById(reminder.id); - expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString()); + // The start of the dose's calendar day in the person's zone. + expect(after.lastSatisfiedAt).toEqual( + startOfLocalDayKey(dateOnlyKey(occurredAt), "Europe/Berlin"), + ); // Re-anchored from the satisfy instant, so the next one lands roughly ten // years out rather than staying on the date it was already overdue on. expect(after.nextDueAt).not.toBeNull(); From d79060e26b60152ecfca4ca786bb78e899b3f0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:56:32 +0100 Subject: [PATCH 08/26] fix(checkups): show a marked-done check-up as done before the refetch The "Mark done" button re-enables as soon as the satisfy call settles, while the row kept reading as due until the list refetch landed, so a second tap stored a second satisfied entry; skip and snooze behaved the same. The row each of the three calls answers with is now written into the cached list on success, so the row moves to its next due date at once, and the refetch follows as before. --- .../use-measurement-reminders-refetch.test.ts | 26 ++++++++++++++++++- src/hooks/use-measurement-reminders.ts | 25 +++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts index 4c4f01db1..35d13d042 100644 --- a/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts +++ b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi } from "vitest"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; +import { + invalidateReminderReads, + putReminderRow, + type MeasurementReminder, +} from "@/hooks/use-measurement-reminders"; import { queryKeys } from "@/lib/query-keys"; /** @@ -71,3 +75,23 @@ describe("invalidateReminderReads — the Vorsorge daily-rail freshness contract client.clear(); }); }); + +describe("putReminderRow", () => { + it("shows the answered row at once, so it no longer reads as due", () => { + const client = new QueryClient(); + const due = { + id: "r1", + nextDueAt: "2026-11-11T08:00:00.000Z", + } as MeasurementReminder; + const other = { id: "r2" } as MeasurementReminder; + client.setQueryData(queryKeys.measurementReminders(), [due, other]); + + const done = { ...due, nextDueAt: "2027-11-11T08:00:00.000Z" }; + putReminderRow(client, done); + + expect(client.getQueryData(queryKeys.measurementReminders())).toEqual([ + done, + other, + ]); + }); +}); diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index d60766302..1dfe06611 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -115,10 +115,29 @@ export function useMeasurementReminders(enabled = true) { }); } +/** + * Write the row a satisfy, skip or snooze answered with into the cached list. + * The button re-enables as the call settles, and with only the refetch behind + * it the row still read as due, so a second tap marked it done twice. + */ +export function putReminderRow( + qc: QueryClient, + reminder: MeasurementReminder, +): void { + qc.setQueryData( + queryKeys.measurementReminders(), + (rows) => rows?.map((row) => (row.id === reminder.id ? reminder : row)), + ); +} + export function useMeasurementReminderMutations() { const qc = useQueryClient(); // Not returned: a mutation would wait for every refetch before it settles. const invalidate = () => void invalidateReminderReads(qc); + const answered = (reminder: MeasurementReminder) => { + putReminderRow(qc, reminder); + invalidate(); + }; const create = useMutation({ mutationKey: queryKeys.measurementReminderCreate(), @@ -150,7 +169,7 @@ export function useMeasurementReminderMutations() { mutationKey: queryKeys.measurementReminderSatisfy(), mutationFn: (id: string) => apiPost(`${BASE}/${id}/satisfy`), - onSuccess: invalidate, + onSuccess: answered, }); // v1.37.20 (#223) — honest skip: the interval restarts from the skip @@ -161,7 +180,7 @@ export function useMeasurementReminderMutations() { apiPost<{ skipped: boolean; reminder: MeasurementReminder }>( `${BASE}/${id}/skip`, ), - onSuccess: invalidate, + onSuccess: ({ reminder }) => answered(reminder), }); // v1.37.20 (#223) — snooze to a calendar day; the server resolves it to @@ -172,7 +191,7 @@ export function useMeasurementReminderMutations() { apiPost<{ reminder: MeasurementReminder }>(`${BASE}/${id}/snooze`, { until, }), - onSuccess: invalidate, + onSuccess: ({ reminder }) => answered(reminder), }); return { create, update, remove, satisfy, skip, snooze }; From df479804548a5f430ac2ae130492372eec195d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:59:45 +0100 Subject: [PATCH 09/26] fix(telegram): pick the running course and judge a reminder by its slot The "taken " keyword looked up the first medication of that name and only then asked whether its course had ended, so an old ended course beside the current one of the same name answered "not found". The lookup now reads every match and takes the first that runs. A reminder button judged the course at the moment of the tap, so the last evening dose of a course, confirmed after midnight, was refused although the web still takes it. When the message names its slot, the course is now judged at that slot. The handler file grows by six lines; its size baseline is raised to match. --- .../baselines/source-file-lines.json | 2 +- .../telegram/webhook/__tests__/route.test.ts | 62 +++++++++++++------ .../telegram-dose-medications.test.ts | 28 +++++++-- src/lib/telegram-dose-medications.ts | 18 ++++-- src/lib/telegram-webhook-handlers.ts | 32 ++++++---- 5 files changed, 100 insertions(+), 42 deletions(-) diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index e49138989..54d329d69 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -82,7 +82,7 @@ "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 806, "lib/support/report.ts": 903, - "lib/telegram-webhook-handlers.ts": 1313, + "lib/telegram-webhook-handlers.ts": 1319, "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 4781f80c2..4226f6012 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -132,7 +132,9 @@ const MEDICATION = { id: "med-1", name: "Ramipril" }; * schedule projection (distinguishable by `select.schedules`); every other * `medication.findFirst` in the webhook wants the bare id/name shape. The * default projection carries no schedules, so the generic dispatch tests - * exercise the ad-hoc (standalone create) path unchanged. + * exercise the ad-hoc (standalone create) path unchanged. The dose lookup + * (`findTelegramDoseMedication`) reads every match by id or name through + * `findMany`; a list read (no id, no name) finds nothing unless a test says. */ function wireMedicationFindFirst( schedules: unknown[] = [], @@ -155,6 +157,14 @@ function wireMedicationFindFirst( } return Promise.resolve(medication); }) as never); + vi.mocked(prisma.medication.findMany).mockImplementation(((args: { + where?: Record; + }) => + Promise.resolve( + args?.where && ("id" in args.where || "name" in args.where) + ? [medication] + : [], + )) as never); } beforeEach(() => { @@ -168,7 +178,6 @@ beforeEach(() => { vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); vi.mocked(isModuleEnabled).mockResolvedValue(true); wireMedicationFindFirst(); - vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.update).mockResolvedValue({} as never); vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never); @@ -305,6 +314,7 @@ describe("Telegram webhook — callback dispatch", () => { expect(res.status).toBe(200); expect(prisma.medication.findFirst).not.toHaveBeenCalled(); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); expect(prisma.medication.update).not.toHaveBeenCalled(); expect(deleteMessage).not.toHaveBeenCalled(); @@ -469,7 +479,7 @@ describe("Telegram webhook — callback dispatch", () => { }); it("rejects 'taken' for an inactive medication and acks with the localised error", async () => { - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); @@ -487,7 +497,7 @@ describe("Telegram webhook — callback dispatch", () => { }); it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => { - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60"))); expect(res.status).toBe(200); expect(prisma.medication.update).not.toHaveBeenCalled(); @@ -675,8 +685,8 @@ describe("Telegram webhook — text-message dispatch", () => { }), ); expect(res.status).toBe(200); - // markMedicationTaken → findFirst on active medication - expect(prisma.medication.findFirst).toHaveBeenCalled(); + // The list, then markMedicationTaken's lookup of the one it found. + expect(prisma.medication.findMany).toHaveBeenCalledTimes(2); expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); }); @@ -717,15 +727,6 @@ describe("Telegram webhook — text-message dispatch", () => { }); it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => { - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ - id: "med-1", - } as never); - // Second findFirst inside markMedicationTaken - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ - id: "med-1", - name: "Ramipril", - } as never); - const res = await POST( tgRequest({ update_id: 206, @@ -767,8 +768,8 @@ describe("Telegram webhook — text-message dispatch", () => { }); it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => { - // First findFirst (lookup by name) returns null - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + // The lookup by name finds nothing. + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); const res = await POST( tgRequest({ @@ -912,6 +913,29 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => { }); }); + it("books the reminder's own dose on the last day of a course after it ended", async () => { + // The course ran through 9 June; yesterday's 09:00 reminder is confirmed + // the next morning. The dose belongs to a day the course covered. + wireMedicationFindFirst([scheduleRow], [], { + ...MEDICATION, + dose: "5 mg", + startsOn: null, + endsOn: new Date("2026-06-09T00:00:00.000Z"), + endedAt: null, + }); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-09", + timeOfDay: "09:00", + } as never); + + await POST(tgRequest(callbackUpdate("taken:med-1"))); + + expect(prisma.$transaction).toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).not.toMatch( + /not found or inactive/i, + ); + }); + it("a take converges via band attribution when no reminder row matches", async () => { vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); @@ -1224,7 +1248,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => { it("the 'taken' keyword resolves tracked medications only", async () => { await POST(tgRequest(textUpdate("taken Ramipril"))); - const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]); + const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]); expect(where).toMatchObject({ active: true, trackIntake: true }); }); @@ -1232,7 +1256,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => { await POST(tgRequest(callbackUpdate("taken:med-1"))); // The gate lookup: a record-only medication resolves to nothing here, // so no dose is written (later lookups only bind an admitted dose). - const calls = vi.mocked(prisma.medication.findFirst).mock.calls; + const calls = vi.mocked(prisma.medication.findMany).mock.calls; expect(calls.length).toBeGreaterThan(0); expect(whereOf(calls[0])).toMatchObject({ id: "med-1", diff --git a/src/lib/__tests__/telegram-dose-medications.test.ts b/src/lib/__tests__/telegram-dose-medications.test.ts index a8fceac86..d0d0af78e 100644 --- a/src/lib/__tests__/telegram-dose-medications.test.ts +++ b/src/lib/__tests__/telegram-dose-medications.test.ts @@ -77,12 +77,12 @@ describe("listTelegramDoseMedications", () => { describe("findTelegramDoseMedication", () => { it("resolves a current course", async () => { - db.findFirst.mockResolvedValue(CHRONIC); + db.findMany.mockResolvedValue([CHRONIC]); const found = await findTelegramDoseMedication("user-1", null, { id: "chronic", }); expect(found?.id).toBe("chronic"); - expect(db.findFirst).toHaveBeenCalledWith( + expect(db.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "chronic", @@ -95,7 +95,7 @@ describe("findTelegramDoseMedication", () => { }); it("refuses an ended course", async () => { - db.findFirst.mockResolvedValue(ENDED_NOW); + db.findMany.mockResolvedValue([ENDED_NOW]); expect( await findTelegramDoseMedication("user-1", "Europe/Berlin", { id: "ended-now", @@ -103,11 +103,31 @@ describe("findTelegramDoseMedication", () => { ).toBeNull(); }); + it("finds the current course beside an ended one of the same name", async () => { + db.findMany.mockResolvedValue([ENDED, CHRONIC]); + const found = await findTelegramDoseMedication("user-1", "Europe/Berlin", { + name: { equals: "ramipril", mode: "insensitive" }, + }); + expect(found?.id).toBe("chronic"); + }); + + it("judges the course at the dose's own slot", async () => { + // The last evening dose, confirmed after midnight once the course ended. + db.findMany.mockResolvedValue([ENDED_NOW]); + const found = await findTelegramDoseMedication( + "user-1", + "Europe/Berlin", + { id: "ended-now" }, + new Date(Date.now() - 3 * 60 * 60 * 1000), + ); + expect(found?.id).toBe("ended-now"); + }); + it("refuses everything with the Medications module off", async () => { db.moduleOn.mockResolvedValue(false); expect( await findTelegramDoseMedication("user-1", null, { id: "chronic" }), ).toBeNull(); - expect(db.findFirst).not.toHaveBeenCalled(); + expect(db.findMany).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/telegram-dose-medications.ts b/src/lib/telegram-dose-medications.ts index 7820d860c..b0eb87d43 100644 --- a/src/lib/telegram-dose-medications.ts +++ b/src/lib/telegram-dose-medications.ts @@ -40,10 +40,10 @@ function courseEnded( endedAt: Date | null; }, timeZone: string | null, + at: Date = new Date(), ): boolean { return ( - courseStatusAt(medication, new Date(), timeZone || DEFAULT_TIMEZONE) === - "ENDED" + courseStatusAt(medication, at, timeZone || DEFAULT_TIMEZONE) === "ENDED" ); } @@ -61,16 +61,24 @@ export async function listTelegramDoseMedications( return medications.filter((m) => !courseEnded(m, timeZone)); } -/** The one medication `where` names, or null when the bot may not act on it. */ +/** + * The medication `where` names, or null when the bot may not act on it. A + * name can match an ended course beside the current one, so the course is + * judged on every match. `at` is the moment the dose is for: a reminder's + * button books its own slot, so the last evening dose of a course can still + * be confirmed after midnight. + */ export async function findTelegramDoseMedication( userId: string, timeZone: string | null, where: Prisma.MedicationWhereInput, + at: Date = new Date(), ) { if (!(await isModuleEnabled(userId, "medications"))) return null; - const medication = await prisma.medication.findFirst({ + const medications = await prisma.medication.findMany({ where: { ...where, userId, active: true, ...TRACKED_INTAKE_WHERE }, select: DOSE_MEDICATION_SELECT, + orderBy: { id: "asc" }, }); - return medication && !courseEnded(medication, timeZone) ? medication : null; + return medications.find((m) => !courseEnded(m, timeZone, at)) ?? null; } diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index c152bff52..7ea752ac5 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -266,9 +266,24 @@ async function markMedicationTaken( reminderRef?: { chatId: string; messageId: number }, ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); - const medication = await findTelegramDoseMedication(userId, userTz, { - id: medicationId, - }); + const tz = userTz || DEFAULT_TIMEZONE; + // The reminder's own slot, when the message names one: the course is + // judged on that dose, not on the moment of the tap. + const reminderSlot = reminderRef + ? await resolveReminderSlot({ + userId, + medicationId, + userTz: tz, + chatId: reminderRef.chatId, + messageId: reminderRef.messageId, + }) + : null; + const medication = await findTelegramDoseMedication( + userId, + userTz, + { id: medicationId }, + reminderSlot ?? undefined, + ); if (!medication) { return { ok: false, message: t("telegram.errorMedicationInactive") }; } @@ -281,7 +296,6 @@ async function markMedicationTaken( let scheduledFor: Date | null = null; if (!existing) { const takenAt = new Date(); - const tz = userTz || DEFAULT_TIMEZONE; // v1.16.9 — converge onto the slot's canonical row instead of bare- // creating a second row anchored at `now`. The old shape left the @@ -289,15 +303,7 @@ async function markMedicationTaken( // and compliance punished a dose the user explicitly confirmed. // Prefer the reminder's own slot (the message names the dose it fired // for); fall back to band attribution on the tap instant. - let canonicalSlot: Date | null = reminderRef - ? await resolveReminderSlot({ - userId, - medicationId: medication.id, - userTz: tz, - chatId: reminderRef.chatId, - messageId: reminderRef.messageId, - }) - : null; + let canonicalSlot: Date | null = reminderSlot; if (canonicalSlot === null) { const attribution = await resolveSlotForWriteByBand({ userId, From a4b14609bf15090f9781c002fc2beba66a321ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 23:00:43 +0100 Subject: [PATCH 10/26] fix(practitioners): offer an Undo for every delete, not only the last Two quick deletes in the address book showed the Undo toast for the second entry only: the callbacks `mutate` takes belong to the latest call of the list's one mutation, so the first entry's were dropped. The toast now hangs on each delete's own promise. --- .../practitioner-delete-undo.test.ts | 43 ++++++++++++++----- .../practitioners/practitioner-list.tsx | 12 +++--- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts index fbe6aa48c..4502ff1ed 100644 --- a/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts +++ b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts @@ -4,6 +4,7 @@ * The delete now confirms with an Undo that calls the restore. */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MutationObserver, QueryClient } from "@tanstack/react-query"; const toastSuccess = vi.hoisted(() => vi.fn()); const errorToast = vi.hoisted(() => vi.fn()); @@ -16,13 +17,9 @@ type Mutations = Parameters[0]; const t = (key: string) => key; -function mutations(restoreResult: Promise) { - const remove = { - mutate: vi.fn((_id: string, options: { onSuccess?: () => void }) => - options.onSuccess?.(), - ), - }; - const restore = { mutateAsync: vi.fn(() => restoreResult) }; +function mutations(restoreResult: () => Promise) { + const remove = { mutateAsync: vi.fn(async () => ({ deleted: true })) }; + const restore = { mutateAsync: vi.fn(restoreResult) }; return { remove, restore, all: { remove, restore } as unknown as Mutations }; } @@ -33,11 +30,12 @@ beforeEach(() => { describe("deleteWithUndo", () => { it("confirms the delete with an Undo that restores the entry", async () => { - const m = mutations(Promise.resolve({ id: "p1" })); + const m = mutations(async () => ({ id: "p1" })); deleteWithUndo(m.all, "p1", t); - expect(m.remove.mutate).toHaveBeenCalledWith("p1", expect.anything()); + expect(m.remove.mutateAsync).toHaveBeenCalledWith("p1"); + await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalled()); const [message, options] = toastSuccess.mock.calls[0]!; expect(message).toBe("practitioners.deleted"); expect(options.action.label).toBe("common.undo"); @@ -48,9 +46,12 @@ describe("deleteWithUndo", () => { }); it("says so when the restore fails", async () => { - const m = mutations(Promise.reject(new Error("offline"))); + const m = mutations(async () => { + throw new Error("offline"); + }); deleteWithUndo(m.all, "p1", t); + await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalled()); toastSuccess.mock.calls[0]![1].action.onClick(); await vi.waitFor(() => @@ -60,4 +61,26 @@ describe("deleteWithUndo", () => { ), ); }); + + it("offers an Undo for each of two quick deletes", async () => { + // One mutation observer for the list, as `useMutation` keeps it: the + // callbacks of an earlier `mutate` are dropped once a later one starts. + const observer = new MutationObserver(new QueryClient(), { + mutationFn: async (id: string) => ({ id }), + }); + const remove = { + mutate: (id: string, options?: object) => + void observer.mutate(id, options).catch(() => undefined), + mutateAsync: (id: string) => observer.mutate(id), + }; + const restore = { mutateAsync: vi.fn(async () => ({})) }; + const all = { remove, restore } as unknown as Mutations; + + deleteWithUndo(all, "p1", t); + deleteWithUndo(all, "p2", t); + + await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(2)); + for (const call of toastSuccess.mock.calls) call[1].action.onClick(); + expect(restore.mutateAsync.mock.calls).toEqual([["p1"], ["p2"]]); + }); }); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index af361afe1..68c2752bb 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -46,15 +46,17 @@ import { PractitionerSheet } from "./practitioner-sheet"; /** * Delete an entry, with an Undo: the delete is soft, and the restore brings - * the entry back. + * the entry back. Each delete answers on its own promise: the callbacks + * `mutate` takes fire for the latest call only, so of two quick deletes only + * the second offered an Undo. */ export function deleteWithUndo( { remove, restore }: ReturnType, id: string, t: (key: string) => string, ) { - remove.mutate(id, { - onSuccess: () => + remove.mutateAsync(id).then( + () => toast.success(t("practitioners.deleted"), { action: { label: t("common.undo"), @@ -66,9 +68,9 @@ export function deleteWithUndo( ), }, }), - onError: (failure) => + (failure) => showErrorToast(t("practitioners.deleteFailed"), { cause: failure }), - }); + ); } export function PractitionerList({ enabled = true }: { enabled?: boolean }) { From 048dbbee9f53bf3f5b986d31acc5e27a6e72ab00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:48:03 +0100 Subject: [PATCH 11/26] fix(measurements): keep computed scores out of the bulk delete The row menu stopped offering edit and delete on a computed score (recovery, strain, stress), but the list still drew its selection checkbox and "select all" took it into the page, so the bulk bar could delete a page of scores, and the tombstones kept the recompute out. A computed row now has no checkbox on either layout and stays out of the page selection, and the bulk-delete route skips computed rows if a client sends them anyway. The iOS app treats these rows as read-only and does not rely on deleting them. --- .../bulk-delete/__tests__/route.test.ts | 14 ++++ src/app/api/measurements/bulk-delete/route.ts | 10 ++- .../list/__tests__/row-actions.test.tsx | 68 ++++++++++++++++++- .../list/measurement-card-list.tsx | 3 +- .../measurements/list/measurement-table.tsx | 6 +- .../list/use-measurement-list-data.ts | 6 +- 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 55f4cb17c..cadf4b3e0 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -156,6 +156,20 @@ describe("POST /api/measurements/bulk-delete", () => { }); }); + it("leaves a computed score out of the selection it deletes", async () => { + // The read is scoped like the write, so a computed row never reaches it. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 0, + } as never); + + await POST(postReq({ ids: ["score-1"] })); + + expect( + vi.mocked(prisma.measurement.findMany).mock.calls[0][0]?.where, + ).toMatchObject({ source: { not: "COMPUTED" } }); + }); + it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => { // 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day, // 1× PULSE same day as the first → 3 distinct (type, day) keys. diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 16e2a22ee..c146b2580 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -81,9 +81,17 @@ async function postBulkDelete(request: NextRequest): Promise { // other half and the cuff's pulse, `bloodPressureSiblings`) into the same // statement, so the shared `deletedAt` lets the restore route bring the // reading back whole. + // + // A computed score (recovery, strain, stress) is skipped: the server + // rewrites it from its inputs, and a tombstone kept the recompute out. const { affected, count } = await prisma.$transaction(async (tx) => { const selected = await tx.measurement.findMany({ - where: { id: { in: ids }, userId: user.id, deletedAt: null }, + where: { + id: { in: ids }, + userId: user.id, + deletedAt: null, + source: { not: "COMPUTED" }, + }, select: { id: true, type: true, source: true, measuredAt: true }, }); const siblings = await bloodPressureSiblings(tx, user.id, selected, "live"); diff --git a/src/components/measurements/list/__tests__/row-actions.test.tsx b/src/components/measurements/list/__tests__/row-actions.test.tsx index e481a9427..db5bd6912 100644 --- a/src/components/measurements/list/__tests__/row-actions.test.tsx +++ b/src/components/measurements/list/__tests__/row-actions.test.tsx @@ -1,7 +1,7 @@ /** - * A computed score offers neither edit nor delete in the measurement list; - * a reading of the person's own, or one a device or provider sent, keeps - * both. + * A computed score offers neither edit nor delete in the measurement list, + * and no selection checkbox for the bulk delete; a reading of the person's + * own, or one a device or provider sent, keeps all three. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -15,6 +15,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import type { Measurement } from "../list-model"; +import { MeasurementCardList } from "../measurement-card-list"; +import { MeasurementTable } from "../measurement-table"; import { MeasurementRowActions, type MeasurementRowContext, @@ -73,3 +75,63 @@ describe("MeasurementRowActions", () => { }, ); }); + +describe("the row checkbox", () => { + const listRows: MeasurementRowContext = { + ...rows, + display: { + rowDisplay: (m: Measurement) => ({ value: m.value, unit: m.unit }), + rowIdentityAttrs: () => ({}), + } as unknown as MeasurementRowContext["display"], + }; + const reading = (id: string, source: string): Measurement => ({ + id, + type: "RECOVERY_SCORE", + value: 85, + unit: "score", + source, + measuredAt: "2026-11-10T12:00:00.000Z", + notes: null, + }); + const checkboxes = (html: string) => + html.split('aria-label="Select row"').length - 1; + + it("is offered for a reading and not for a computed score, on both layouts", () => { + const measurements = [reading("m1", "MANUAL"), reading("m2", "COMPUTED")]; + const wrap = (node: React.ReactNode) => + renderToStaticMarkup( + {node}, + ); + expect( + checkboxes( + wrap( + , + ), + ), + ).toBe(1); + expect( + checkboxes( + wrap( + {}, + } as unknown as Parameters[0]["filters"] + } + list={ + { + selectAll: "none", + pageIds: [], + onToggleSelectAll: () => {}, + } as unknown as Parameters[0]["list"] + } + />, + ), + ), + ).toBe(1); + }); +}); diff --git a/src/components/measurements/list/measurement-card-list.tsx b/src/components/measurements/list/measurement-card-list.tsx index ce5f90d09..fdc48cf75 100644 --- a/src/components/measurements/list/measurement-card-list.tsx +++ b/src/components/measurements/list/measurement-card-list.tsx @@ -18,6 +18,7 @@ import { import { DayDrillDown, SleepNightCaption } from "./day-drill-down"; import { MeasurementRowActions, + isServerComputed, rowState, typeLabel, type MeasurementRowContext, @@ -83,7 +84,7 @@ function MeasurementCard({ >
- {!isGrouped && canManage && ( + {!isGrouped && !isServerComputed(m) && canManage && ( // The checkbox owns its 44 px hit area through an `after` // hit-slop inside a plain layout `
`: a wrapper `
); } + +/** + * The line under a picker list cut at one page: the search reaches the rest, + * and while one runs, a narrower one does. Shared by the vault picker and + * the Coach's attachment picker. + */ +export function DocumentPickerCapNotice({ + cut, + count, + searching, +}: { + cut: boolean; + count: number; + searching: boolean; +}) { + const { t } = useTranslations(); + return ( + + ); +} diff --git a/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx index a4d117b05..202477891 100644 --- a/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx +++ b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx @@ -56,14 +56,14 @@ describe("AttachmentPicker", () => { it("says the list is cut, and how to reach the rest", () => { page.nextCursor = "d49"; const html = render(); - expect(html).toContain('data-slot="coach-attachment-picker-capped"'); + expect(html).toContain('data-slot="list-cap-notice"'); expect(html).toContain("Showing the newest 50"); }); it("says nothing when every document is listed", () => { page.nextCursor = null; const html = render(); - expect(html).not.toContain('data-slot="coach-attachment-picker-capped"'); + expect(html).not.toContain('data-slot="list-cap-notice"'); expect( html.match(/data-slot="coach-attachment-picker-item"/g), ).toHaveLength(3); diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index b86fd9fa1..0fefc9684 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; +import { DocumentPickerCapNotice } from "@/components/documents/document-picker-sheet"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { EmptyState } from "@/components/ui/empty-state"; @@ -266,16 +267,11 @@ export function AttachmentPicker({ })} )} - {cut && !list.isError ? ( -

- {t("documents.picker.capped", { - count: String(list.data?.documents.length ?? 0), - })} -

- ) : null} +
); diff --git a/src/components/ui/list-cap-notice.tsx b/src/components/ui/list-cap-notice.tsx index c1fe6160b..1664fc6de 100644 --- a/src/components/ui/list-cap-notice.tsx +++ b/src/components/ui/list-cap-notice.tsx @@ -8,14 +8,17 @@ import { cn } from "@/lib/utils"; * The line under a list the server cut at its ceiling. Renders nothing for a * complete list, so a surface passes the cap it read (`apiGetList`) and * never decides on its own whether to show it. Meta tier: one muted sentence - * at the `text-xs` floor. + * at the `text-xs` floor. `message` replaces the generic sentence where the + * surface can say how to reach the rest (a search). */ export function ListCapNotice({ cap, className, + message, }: { cap: ListCap | null | undefined; className?: string; + message?: string; }) { const { t } = useTranslations(); if (!cap?.truncated) return null; @@ -24,7 +27,7 @@ export function ListCapNotice({ data-slot="list-cap-notice" className={cn("text-muted-foreground text-xs", className)} > - {t("common.listCapped", { count: String(cap.limit) })} + {message ?? t("common.listCapped", { count: String(cap.limit) })}

); } From 706af61cdd865f2811c67a20c32d5cfa77907df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:52:39 +0100 Subject: [PATCH 15/26] fix(custom-metrics): say "name taken" only for a name clash The form read every 409 as "a metric with this name already exists". Now that it keeps one idempotency key across taps, a second tap while the first create is still running also answers 409, and the form told the person their name was taken. The create and rename routes send `customMetric.name_taken` with the clash, and the form matches the code. --- src/app/api/custom-metrics/[id]/__tests__/route.test.ts | 1 + src/app/api/custom-metrics/[id]/route.ts | 4 +++- src/app/api/custom-metrics/__tests__/route.test.ts | 3 +++ src/app/api/custom-metrics/route.ts | 4 +++- src/components/custom-metrics/custom-metric-form.tsx | 4 +++- src/lib/openapi/error-codes.ts | 1 + 6 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts index 76d08eb28..f9f693eb4 100644 --- a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts @@ -225,6 +225,7 @@ describe("PATCH /api/custom-metrics/[id]", () => { const res = await PATCH(patchReq({ name: "Other" }), params("cm-1")); expect(res.status).toBe(409); + expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken"); expect(prisma.customMetric.update).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 2b149bd4d..a26fdbe35 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -111,7 +111,9 @@ export const PATCH = apiHandler( select: { id: true }, }); if (clash) { - return apiError("A custom metric with this name already exists", 409); + return apiError("A custom metric with this name already exists", 409, { + errorCode: "customMetric.name_taken", + }); } } diff --git a/src/app/api/custom-metrics/__tests__/route.test.ts b/src/app/api/custom-metrics/__tests__/route.test.ts index 1a9a0492c..fba6c5514 100644 --- a/src/app/api/custom-metrics/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/__tests__/route.test.ts @@ -134,6 +134,9 @@ describe("POST /api/custom-metrics", () => { const res = await POST(postReq({ name: "Grip strength", unit: "kg" })); expect(res.status).toBe(409); + // The form names the clash by this code: a 409 without it can be the + // first save still running under the same key. + expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken"); expect(prisma.customMetric.create).not.toHaveBeenCalled(); }); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index dfc9aa12e..88fcc1bca 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -100,7 +100,9 @@ async function postCustomMetric(request: NextRequest) { select: { id: true, deletedAt: true }, }); if (existing && existing.deletedAt === null) { - return apiError("A custom metric with this name already exists", 409); + return apiError("A custom metric with this name already exists", 409, { + errorCode: "customMetric.name_taken", + }); } // Field-by-field assignment — never spread `parsed.data`. diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index e0cb0a053..664fdd911 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -132,8 +132,10 @@ export function CustomMetricForm({ ); onSuccess?.(saved); } catch (err) { + // By its code: a 409 can also be the first save still running. setError( - err instanceof ApiError && err.status === 409 + err instanceof ApiError && + err.meta?.errorCode === "customMetric.name_taken" ? t("customMetrics.form.nameTaken") : localizedApiError(err, t, "customMetrics.form.saveError"), ); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index fa30f1bf2..ac7344c5a 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -140,6 +140,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "coach-prefs.body.invalid_shape", ], consent: ["consent.ai.confirmRequired", "consent.ai.required"], + customMetric: ["customMetric.name_taken"], cycle: [ "cycle.bulk.invalid", "cycle.bulk.too_large", From 55b223d4ed27e66a76fea3b33c826c18bae09d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:53:12 +0100 Subject: [PATCH 16/26] fix(onboarding): show the closing AI line as body copy The done screen's "AI is optional" line is a sentence the person reads, set in muted text, which the UI standards keep for meta. It is body text now. Its test asserted the absence of strings deleted from every bundle in the same change, so it could not fail; it now checks that the line is not muted, that the screen links to the AI settings once, and that no button sits beside the link. --- .../onboarding/__tests__/done-screen.test.tsx | 10 +++++----- src/components/onboarding/done-screen.tsx | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index f9113451e..946136252 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -117,11 +117,11 @@ describe(" AI line", () => { ); expect(line).not.toBeNull(); expect(line![1]).toContain("AI is optional"); - expect(line![1]).toContain('href="/settings/ai"'); - // No sample briefing, no provider ladder, no one-tap offer. - expect(html).not.toContain("Example"); - expect(html).not.toContain("Three ways to turn it on"); - expect(html).not.toMatch(/]*>[^<]*Turn on/); + // Body copy, not meta: the line is read, so it is not muted. + expect(line![0]).not.toContain("text-muted-foreground"); + // One way on, the link, and no one-tap offer beside it. + expect(html.split('href="/settings/ai"')).toHaveLength(2); + expect(line![1]).not.toContain(" { diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index c95709db9..ac15f61f3 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -123,10 +123,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
{showAiLine ? ( -

+

{t("onboarding.done.aiOptional")}{" "} {t("onboarding.done.aiSetupCta")} From cad4c89831c22706dcbc44fa889479fb6e8d8ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:53:36 +0100 Subject: [PATCH 17/26] test: mark the visit, dose and address-book deletes restorable The three deletes gained an Undo toast that calls the restore route, but the destructive-control registry still listed them as tombstoned with no restore, with comments saying they would become restorable the day a surface offered the undo. The correlation card test's header still described the disabled "Coming soon" action it no longer tests. --- src/__tests__/destructive-control-guard.test.ts | 13 +++++-------- .../insights/__tests__/correlation-card.test.tsx | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 3bdc790fb..762adce82 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -469,17 +469,15 @@ const REGISTRY: DestructiveEntry[] = [ destroys: "a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it", triggers: ["components/encounters/encounter-sheet.tsx"], - // The route soft-deletes and a restore route exists, but nothing in the - // product reaches it yet, so from a person's side the visit is gone. This - // becomes "restorable" the day a surface offers the undo, not before. - recovery: "tombstoned-no-restore", + // The delete toast offers the Undo that calls the restore route. + recovery: "restorable", confirm: ["AlertDialog"], }, { file: "hooks/use-practitioners.ts", destroys: "one address-book entry", triggers: ["components/practitioners/practitioner-list.tsx"], - recovery: "tombstoned-no-restore", + recovery: "restorable", confirm: ["AlertDialog"], }, { @@ -487,9 +485,8 @@ const REGISTRY: DestructiveEntry[] = [ destroys: "one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound", triggers: ["components/vaccinations/vaccination-sheet.tsx"], - // The route soft-deletes and a restore route exists, but no surface offers - // the undo yet, so from a person's side the dose is gone. - recovery: "tombstoned-no-restore", + // The delete toast offers the Undo that calls the restore route. + recovery: "restorable", confirm: ["AlertDialog"], }, diff --git a/src/components/insights/__tests__/correlation-card.test.tsx b/src/components/insights/__tests__/correlation-card.test.tsx index c6d067910..dfb19997c 100644 --- a/src/components/insights/__tests__/correlation-card.test.tsx +++ b/src/components/insights/__tests__/correlation-card.test.tsx @@ -7,9 +7,9 @@ import type { CorrelationResult } from "@/lib/insights/correlations"; * v1.4.20 — `` rendering. * * Tests cover: - * 1. OK status renders title + interpretation + source chip + disabled CTA. + * 1. OK status renders title + interpretation + source chip. * 2. Insufficient status renders the empty-state instead. - * 3. CTA is `disabled` and exposes the "Coming soon" tooltip via `title`. + * 3. The card offers no placeholder action, only ones that work. * 4. Confidence chip surfaces high / moderate / low. * 5. Locale-aware copy (EN / DE). */ From 2d1009136a0c8bc8bee7bdabb2d9e9f40a609523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:54:20 +0100 Subject: [PATCH 18/26] chore: drop the em dashes this release added to prose and test titles The notification categories' contract description, which lands in the published API document, and a handful of new code comments and test titles used em dashes; the house style keeps them out of new prose. --- src/__tests__/display-transform-rounding-guard.test.ts | 2 +- src/app/api/telegram/webhook/__tests__/route.test.ts | 4 ++-- .../__tests__/medication-intake-quick-add.test.tsx | 2 +- src/components/documents/document-picker-sheet.tsx | 6 +++--- src/lib/ai/provider.ts | 2 +- src/lib/insights/__tests__/blood-pressure-status.test.ts | 2 +- src/lib/insights/__tests__/features.test.ts | 2 +- src/lib/openapi/routes/notifications.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index 8d0a4a5fd..b24003542 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -45,7 +45,7 @@ const REGISTRY = "lib/measurements/display-transform.ts"; /** * The one module allowed to import the unrounded escape hatch. The target * adapter needs the unrounded pound edge of a canonical guardrail so it can - * round it INWARD — rounding to the nearest tenth first can put the pound + * round it INWARD: rounding to the nearest tenth first can put the pound * edge outside the kilogram window, and the server rejects what converts * back from it. */ diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 4226f6012..f9fa42f50 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -1272,7 +1272,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => { * silent, so a reminder button left in the chat, the /add list and the * "taken" keyword do nothing either. */ -describe("POST /api/telegram/webhook — Medications module off", () => { +describe("POST /api/telegram/webhook with the Medications module off", () => { function textUpdate(text: string) { return { update_id: 400, @@ -1323,7 +1323,7 @@ describe("POST /api/telegram/webhook — Medications module off", () => { * since ended, which would also take a unit off the stock. The list side is * pinned in `src/lib/__tests__/telegram-dose-medications.test.ts`. */ -describe("POST /api/telegram/webhook — ended course", () => { +describe("POST /api/telegram/webhook on an ended course", () => { const DAY_MS = 24 * 60 * 60 * 1000; const ENDED = { ...MEDICATION, diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 28ab5a317..aa768cdd7 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -408,7 +408,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not }); it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => { - // The helper the submit path calls — asserted directly, same shape as + // The helper the submit path calls, asserted directly, same shape as // the take-all-due suite: the bundle fans out (N keys), then the two // daily reads are invalidated with `refetchType: "inactive"`, which is // what actually refetches them while unmounted. diff --git a/src/components/documents/document-picker-sheet.tsx b/src/components/documents/document-picker-sheet.tsx index 41bf5f182..140750ccf 100644 --- a/src/components/documents/document-picker-sheet.tsx +++ b/src/components/documents/document-picker-sheet.tsx @@ -2,9 +2,9 @@ /** * The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on - * phones) with a debounced search over the person's own vault — the same - * recall path as the vault page, one page (50) of matches with a line that - * says when more exist, the search input as the way to an older document — + * phones) with a debounced search over the person's own vault (the same + * recall path as the vault page, one page of 50 matches with a line that + * says when more exist, the search input as the way to an older document) * and one toggle row per document. * * Two pickers used to carry this whole body as copies: linking documents to diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index d51551682..11b5b90b0 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -1355,7 +1355,7 @@ async function resolveProviderForTestUnbound( // Empty selection ("test my saved config" — the ai-section calls // /api/ai/test with an empty body): resolve via the SAME path generation - // uses — the first entry of `resolveProviderChain`. The legacy codex→admin + // uses, the first entry of `resolveProviderChain`. The legacy codex→admin // fallback used to probe a different provider than generation ran // (observed: test→admin-key while generation→codex), so a green test gave // no signal about whether overnight generation would work. diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 872190a88..dccc3209e 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -346,7 +346,7 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation" }); }); -describe("generateBloodPressureStatusForUser — the person's own target", () => { +describe("generateBloodPressureStatusForUser and the person's own target", () => { const readings = [1, 2, 3].flatMap((daysAgo) => { const measuredAt = new Date(Date.now() - daysAgo * dayMs); return [ diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index fbdd0fdd6..25470b91c 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -422,7 +422,7 @@ describe("extractFeatures — weight target direction (#1006)", () => { }); }); -describe("extractFeatures — blood pressure against the person's own target", () => { +describe("extractFeatures: blood pressure against the person's own target", () => { // 135/85 on three days: above the age default's ceiling, inside a target // the person's doctor agreed (up to 140/90). const bpRows = [1, 2, 3].flatMap((daysAgo) => { diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index b3ab7d429..d83d218e4 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -76,7 +76,7 @@ const notificationPreferencesResponse = z eventTypes: z .array(z.string()) .describe( - "The event categories this account is shown a switch for, in declaration order — the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.", + "The event categories this account is shown a switch for, in declaration order: the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.", ), eventDefaults: z .record(eventTypeEnum, z.boolean()) From e71b1b9fd4a09086cabc112e66c1e0930cb5b962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:55:15 +0100 Subject: [PATCH 19/26] fix(coach): leave no plan result to invent once a plan is reviewed The plan review rule told the Coach to read how a plan went from its progress line, but a progress line exists only for an active plan, and the review sets the plan to reviewed the morning its reminder surfaces. For the plan being discussed there was nothing to read, so the model could make a result up. The rule now reads a result only from a progress line the snapshot carries, and otherwise asks how it went. --- src/lib/ai/coach/__tests__/system-prompt.test.ts | 14 ++++++++++++++ src/lib/ai/coach/system-prompt.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 11d58e3b1..f36902bf0 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -336,3 +336,17 @@ describe("getCoachSystemPrompt — native locales name the reader's unit", () => }, ); }); + +describe("the plan review rule", () => { + // A reviewed plan has no progress line, so the rule must not leave the + // model a result to make up. + it.each([ + ["en", "Without one you have no result: ask how it went"], + ["de", "Ohne Fortschrittszeile hast du kein Ergebnis"], + ] as const)( + "names no result without a progress line (%s)", + (locale, rule) => { + expect(getCoachSystemPrompt(locale)).toContain(rule); + }, + ); +}); diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 559c77c3a..69c9b7ac7 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -1419,8 +1419,8 @@ function buildCoachV122Addendum(locale: Locale): string { parts.push( isDe - ? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es aus seiner Fortschrittszeile in den eigenen Zahlen des Nutzers zurück, rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.` - : `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back from its progress line in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`, + ? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es NUR aus einer Fortschrittszeile zurück, die der Snapshot für diesen Plan trägt, in den eigenen Zahlen des Nutzers. Ohne Fortschrittszeile hast du kein Ergebnis: frag, wie es gelaufen ist, statt eines zu nennen. Bleib rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.` + : `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back ONLY from a progress line the snapshot carries for that plan, in the user's own numbers. Without one you have no result: ask how it went instead of naming one. Stay association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`, ); return parts.join("\n\n"); From 31c44401544f8b723103d3397e9bd69e464bf8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:58:37 +0100 Subject: [PATCH 20/26] refactor(custom-metrics): read a refused save in one helper Matching the name clash by its code grew the form component past its size baseline. The refusal's sentence now comes from a helper beside it. --- .../custom-metrics/custom-metric-form.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index 664fdd911..bec187da1 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -60,6 +60,17 @@ interface CustomMetricFormProps { footerSlot?: HTMLElement | null; } +/** + * The sentence for a refused save. A name clash is read by its code: a 409 + * can also be the first save still running under the same key. + */ +function saveRefusal(err: unknown, t: (key: string) => string): string { + return err instanceof ApiError && + err.meta?.errorCode === "customMetric.name_taken" + ? t("customMetrics.form.nameTaken") + : localizedApiError(err, t, "customMetrics.form.saveError"); +} + /** * v1.25.5 — define or edit a custom metric. * @@ -132,13 +143,7 @@ export function CustomMetricForm({ ); onSuccess?.(saved); } catch (err) { - // By its code: a 409 can also be the first save still running. - setError( - err instanceof ApiError && - err.meta?.errorCode === "customMetric.name_taken" - ? t("customMetrics.form.nameTaken") - : localizedApiError(err, t, "customMetrics.form.saveError"), - ); + setError(saveRefusal(err, t)); } finally { setSubmitting(false); } From 8c9fbbdd2bdd167c81af5cddb3290ff1ec58e9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:58:52 +0100 Subject: [PATCH 21/26] fix(errors): name three refusals that fell back to a generic sentence Since a codeless refusal reads by its status, three answers lost their meaning. A failed ChatGPT sign-in poll said "Error saving" instead of asking to start the sign-in again; it now falls back to the sign-in's own failure sentence. A dose-history import still running when the card stopped watching said "Nothing was written" although the job may still write; that case now has its own sentence. Setting up an authenticator while one is active answered "setup failed"; the route now sends `auth.mfa.already_active`, which says to disable the active one first, in every locale. --- messages/de.json | 4 +++ messages/en.json | 4 +++ messages/es.json | 4 +++ messages/fr.json | 4 +++ messages/it.json | 4 +++ messages/ko.json | 4 +++ messages/nl.json | 4 +++ messages/pl.json | 4 +++ messages/pt-BR.json | 4 +++ messages/ru.json | 4 +++ src/app/api/auth/me/mfa/totp/setup/route.ts | 8 +++++- .../settings/ai/codex-provider-form.tsx | 4 +-- .../import-panel/dose-history-import-card.tsx | 25 ++++++++++++++----- src/lib/api/__tests__/localized-error.test.ts | 19 ++++++++++++++ .../__tests__/intake-import-poll.test.ts | 20 ++++++++++++++- src/lib/medications/intake-import-poll.ts | 10 +++++++- src/lib/openapi/error-codes.ts | 1 + 17 files changed, 116 insertions(+), 11 deletions(-) diff --git a/messages/de.json b/messages/de.json index 1e9dc7a3b..72af59b85 100644 --- a/messages/de.json +++ b/messages/de.json @@ -148,6 +148,9 @@ "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", "passkey": { "last_method": "Dieser Passkey ist deine einzige Anmeldemöglichkeit. Lege zuerst ein Passwort fest oder füge einen weiteren Passkey hinzu." + }, + "mfa": { + "already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.", "workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.", "failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.", + "stillRunning": "Der Import dauert länger als üblich und kann noch fertig werden. Sieh dir deine Einnahmen in ein paar Minuten noch einmal an.", "columnsHeading": "Was mit jeder Spalte passiert", "jsonOnly": "nur JSON", "previewLine": "{rows} Zeilen gelesen, {queued} würden importiert, {refused} abgewiesen", diff --git a/messages/en.json b/messages/en.json index 1a5813a7f..e9a22bc8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -148,6 +148,9 @@ "missing": "Your session has ended, so nothing was changed. Sign in again to continue.", "passkey": { "last_method": "This passkey is your only way to sign in. Set a password or add another passkey first." + }, + "mfa": { + "already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "A dose-history import is already running. Wait for it to finish.", "workerUnavailable": "The background worker is not available, so nothing was imported.", "failed": "The import failed. Nothing was written.", + "stillRunning": "The import is taking longer than usual and may still finish. Look at your doses again in a few minutes.", "columnsHeading": "What each column does", "jsonOnly": "JSON only", "previewLine": "{rows} rows read, {queued} would be imported, {refused} refused", diff --git a/messages/es.json b/messages/es.json index 24e1a2b7f..6a78e4f48 100644 --- a/messages/es.json +++ b/messages/es.json @@ -148,6 +148,9 @@ "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", "passkey": { "last_method": "Esta clave de acceso es tu única forma de iniciar sesión. Primero establece una contraseña o añade otra clave de acceso." + }, + "mfa": { + "already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Ya hay una importación de historial en curso. Espera a que termine.", "workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.", "failed": "La importación falló. No se escribió nada.", + "stillRunning": "La importación está tardando más de lo habitual y aún puede terminar. Vuelve a mirar tus dosis dentro de unos minutos.", "columnsHeading": "Qué hace cada columna", "jsonOnly": "solo JSON", "previewLine": "{rows} filas leídas, {queued} se importarían, {refused} rechazadas", diff --git a/messages/fr.json b/messages/fr.json index 81372b683..a1605cce0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -148,6 +148,9 @@ "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", "passkey": { "last_method": "Cette clé d’accès est votre seul moyen de connexion. Définissez d’abord un mot de passe ou ajoutez une autre clé d’accès." + }, + "mfa": { + "already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.", "workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.", "failed": "L'import a échoué. Rien n'a été écrit.", + "stillRunning": "L'import prend plus de temps que d'habitude et peut encore aboutir. Revenez voir vos prises dans quelques minutes.", "columnsHeading": "Ce que devient chaque colonne", "jsonOnly": "JSON uniquement", "previewLine": "{rows} lignes lues, {queued} seraient importées, {refused} refusées", diff --git a/messages/it.json b/messages/it.json index 393f83a57..2bb134ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -148,6 +148,9 @@ "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", "passkey": { "last_method": "Questa passkey è il tuo unico modo per accedere. Imposta prima una password o aggiungi un’altra passkey." + }, + "mfa": { + "already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.", "workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.", "failed": "L'importazione è fallita. Non è stato scritto nulla.", + "stillRunning": "L'importazione richiede più tempo del solito e potrebbe ancora completarsi. Ricontrolla le tue assunzioni tra qualche minuto.", "columnsHeading": "Cosa fa ogni colonna", "jsonOnly": "solo JSON", "previewLine": "{rows} righe lette, {queued} da importare, {refused} rifiutate", diff --git a/messages/ko.json b/messages/ko.json index c02d82e0a..ef1b5cdca 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -148,6 +148,9 @@ "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", "passkey": { "last_method": "이 패스키가 유일한 로그인 수단이에요. 먼저 비밀번호를 설정하거나 패스키를 하나 더 추가하세요." + }, + "mfa": { + "already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "복용 이력 불러오기가 이미 진행 중이에요. 끝날 때까지 기다려 주세요.", "workerUnavailable": "백그라운드 작업자를 쓸 수 없어서 아무것도 불러오지 못했어요.", "failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.", + "stillRunning": "불러오기가 평소보다 오래 걸리고 있어요. 아직 끝날 수 있으니 몇 분 뒤에 복용 기록을 다시 확인해 주세요.", "columnsHeading": "각 열의 역할", "jsonOnly": "JSON 전용", "previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개", diff --git a/messages/nl.json b/messages/nl.json index 4e9615f22..c58a5138d 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -148,6 +148,9 @@ "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", "passkey": { "last_method": "Deze passkey is je enige manier om in te loggen. Stel eerst een wachtwoord in of voeg nog een passkey toe." + }, + "mfa": { + "already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Er loopt al een import van doseringsgeschiedenis. Wacht tot die klaar is.", "workerUnavailable": "De achtergrondverwerking is niet beschikbaar, dus er is niets geïmporteerd.", "failed": "De import is mislukt. Er is niets opgeslagen.", + "stillRunning": "De import duurt langer dan normaal en kan nog afronden. Kijk over een paar minuten opnieuw naar je innames.", "columnsHeading": "Wat elke kolom doet", "jsonOnly": "Alleen JSON", "previewLine": "{rows} rijen gelezen, {queued} zouden worden geïmporteerd, {refused} geweigerd", diff --git a/messages/pl.json b/messages/pl.json index 0a2b41502..d252e10a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -148,6 +148,9 @@ "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", "passkey": { "last_method": "Ten klucz dostępu to Twój jedyny sposób logowania. Najpierw ustaw hasło albo dodaj kolejny klucz dostępu." + }, + "mfa": { + "already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.", "workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.", "failed": "Import nie udał się. Nic nie zostało zapisane.", + "stillRunning": "Import trwa dłużej niż zwykle i może się jeszcze zakończyć. Sprawdź swoje dawki ponownie za kilka minut.", "columnsHeading": "Co się dzieje z każdą kolumną", "jsonOnly": "tylko JSON", "previewLine": "Odczytane wiersze: {rows}, do zaimportowania: {queued}, odrzucone: {refused}", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 41ad972f5..0126af071 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -148,6 +148,9 @@ "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", "passkey": { "last_method": "Esta chave de acesso é sua única forma de entrar. Defina uma senha ou adicione outra chave de acesso primeiro." + }, + "mfa": { + "already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Uma importação de histórico de doses já está em andamento. Espere ela terminar.", "workerUnavailable": "O worker em segundo plano não está disponível, então nada foi importado.", "failed": "A importação falhou. Nada foi gravado.", + "stillRunning": "A importação está demorando mais que o normal e ainda pode terminar. Confira suas doses de novo em alguns minutos.", "columnsHeading": "O que cada coluna faz", "jsonOnly": "Somente JSON", "previewLine": "{rows} linhas lidas, {queued} seriam importadas, {refused} recusadas", diff --git a/messages/ru.json b/messages/ru.json index 5d8632c7c..2ded3fb25 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -148,6 +148,9 @@ "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", "passkey": { "last_method": "Этот ключ доступа остаётся вашим единственным способом входа. Сначала задайте пароль или добавьте ещё один ключ доступа." + }, + "mfa": { + "already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Импорт истории доз уже выполняется. Дождитесь его завершения.", "workerUnavailable": "Фоновый обработчик недоступен, поэтому ничего не импортировано.", "failed": "Импорт не удался. Ничего не записано.", + "stillRunning": "Импорт идёт дольше обычного и ещё может завершиться. Проверьте свои приёмы через несколько минут.", "columnsHeading": "Что делает каждый столбец", "jsonOnly": "Только JSON", "previewLine": "Прочитано строк: {rows}, будет импортировано: {queued}, отклонено: {refused}", diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index 9c94d919e..06c78fd52 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -37,6 +37,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp"; import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof"; +const TOTP_ALREADY_ACTIVE_CODE = "auth.mfa.already_active"; + export const dynamic = "force-dynamic"; const SETUP_RATE_LIMIT = 5; @@ -64,7 +66,11 @@ export const POST = apiHandler(async (req: Request) => { // live secret out from under the user's authenticator. if (user.totpConfirmedAt) { annotate({ action: { name: "auth.mfa.totp.setup.already_active" } }); - throw new HttpError(409, "A second factor is already active"); + throw new HttpError( + 409, + "A second factor is already active", + TOTP_ALREADY_ACTIVE_CODE, + ); } if (auth.transport === "cookie") { diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index ddb8820d4..1378084c5 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -89,7 +89,7 @@ export function CodexProviderForm({ intervalSeconds: data.intervalSeconds, }); } catch (err) { - setMsg(localizedApiError(err, t, "settings.savingError")); + setMsg(localizedApiError(err, t, "settings.codexConnectionFailed")); setMsgType("error"); setDevicePolling(false); } @@ -117,7 +117,7 @@ export function CodexProviderForm({ if (!cancelled) setTimeout(tick, intervalMs); } catch (err) { if (cancelled) return; - setMsg(localizedApiError(err, t, "settings.savingError")); + setMsg(localizedApiError(err, t, "settings.codexConnectionFailed")); setMsgType("error"); setDeviceCode(null); setDevicePolling(false); diff --git a/src/components/settings/import-panel/dose-history-import-card.tsx b/src/components/settings/import-panel/dose-history-import-card.tsx index 1b31532e4..cd5bf5ab7 100644 --- a/src/components/settings/import-panel/dose-history-import-card.tsx +++ b/src/components/settings/import-panel/dose-history-import-card.tsx @@ -31,7 +31,10 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll"; +import { + MedicationIntakeImportTimeout, + waitForMedicationIntakeImport, +} from "@/lib/medications/intake-import-poll"; import { invalidateMedicationReads } from "@/lib/query-keys"; import { DoseHistoryColumnRulings } from "./dose-history-columns"; @@ -93,6 +96,17 @@ function looksLikeJson(text: string): boolean { return head === "[" || head === "{"; } +/** + * The sentence for an import that did not report a result. A failed run or + * a transport failure wrote nothing; a job still running when the watch gave + * up may still write, so it must not read as nothing written. + */ +function importFailureKey(err: unknown): string { + return err instanceof MedicationIntakeImportTimeout + ? "settings.sections.export.import.doseHistory.stillRunning" + : "settings.sections.export.import.doseHistory.failed"; +} + export function DoseHistoryImportCard() { const { t } = useTranslations(); const fatalMessageFor = useFatalMessage(); @@ -191,12 +205,11 @@ export function DoseHistoryImportCard() { skippedDetailsOmitted: result.skippedDetailsOmitted, }); void invalidateMedicationReads(queryClient); - } catch { + } catch (err) { // A failed job's reason is the worker's English and adds nothing; - // anything else is a transport failure. Either way the run did not - // write, and the card says so rather than leaving the last verdict on - // screen. - setError(t("settings.sections.export.import.doseHistory.failed")); + // anything else is a transport failure. The card says what that means + // rather than leaving the last verdict on screen. + setError(t(importFailureKey(err))); setVerdict(null); } finally { setBusy(false); diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index 33bc278f0..3be2a4630 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -126,6 +126,21 @@ describe("localizedApiError", () => { ["a rate limit", 429, "http.rate_limited", "too many requests"], ["a missing right", 403, "http.forbidden", "permission"], ["a rejected entry", 422, "http.invalid", "Check them"], + ["a mistyped proof", 401, "auth.reproof.failed", "did not verify"], + [ + "a password where a second factor is needed", + 422, + "auth.reproof.too_weak", + "second factor", + ], + [ + "an authenticator already set up", + 409, + "auth.mfa.already_active", + "Disable it first", + ], + ["nothing to test with", 422, "ai.provider.none", "No AI provider"], + ["a spent AI budget", 429, "ai.budget.exceeded", "budget is used up"], ])("names %s instead of the save fallback", (_, status, code, text) => { const error = new ApiError("raw English server prose", status, { errorCode: code, @@ -157,6 +172,10 @@ describe("localizedApiError", () => { "http.rate_limited", "http.forbidden", "http.invalid", + "auth.reproof.too_weak", + "auth.mfa.already_active", + "ai.provider.none", + "ai.budget.exceeded", ]) { const sentence = localT(`apiErrors.${code}`); expect(sentence).not.toBe(`apiErrors.${code}`); diff --git a/src/lib/medications/__tests__/intake-import-poll.test.ts b/src/lib/medications/__tests__/intake-import-poll.test.ts index 3088ec569..2117fd5d1 100644 --- a/src/lib/medications/__tests__/intake-import-poll.test.ts +++ b/src/lib/medications/__tests__/intake-import-poll.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { waitForMedicationIntakeImport } from "../intake-import-poll"; +import { + MedicationIntakeImportTimeout, + waitForMedicationIntakeImport, +} from "../intake-import-poll"; describe("waitForMedicationIntakeImport", () => { it("polls queued and running jobs until the durable result is done", async () => { @@ -53,4 +56,19 @@ describe("waitForMedicationIntakeImport", () => { expect(readStatus).toHaveBeenCalledOnce(); expect(wait).not.toHaveBeenCalled(); }); + + it("tells a watch that gave up from a job that failed", async () => { + // The job may still write, so the card must not say nothing was written. + const readStatus = vi.fn().mockResolvedValue({ + status: "running", + result: null, + }); + await expect( + waitForMedicationIntakeImport("/api/import/job-1/status", { + readStatus, + wait: vi.fn().mockResolvedValue(undefined), + maxAttempts: 2, + }), + ).rejects.toBeInstanceOf(MedicationIntakeImportTimeout); + }); }); diff --git a/src/lib/medications/intake-import-poll.ts b/src/lib/medications/intake-import-poll.ts index 9c9af0050..c11117422 100644 --- a/src/lib/medications/intake-import-poll.ts +++ b/src/lib/medications/intake-import-poll.ts @@ -29,6 +29,14 @@ export class MedicationIntakeImportError extends Error { } } +/** The job did not settle while the client watched; it may still write. */ +export class MedicationIntakeImportTimeout extends MedicationIntakeImportError { + constructor() { + super("Medication intake import timed out"); + this.name = "MedicationIntakeImportTimeout"; + } +} + const waitForNextPoll = () => new Promise((resolve) => setTimeout(resolve, 1_000)); @@ -52,5 +60,5 @@ export async function waitForMedicationIntakeImport( if (attempt + 1 < maxAttempts) await wait(); } - throw new MedicationIntakeImportError("Medication intake import timed out"); + throw new MedicationIntakeImportTimeout(); } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index ac7344c5a..8a1df35c2 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -86,6 +86,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.account.deletion_pending", "auth.admin.required", "auth.already_authenticated", + "auth.mfa.already_active", "auth.mfa.code_invalid", "auth.missing", "auth.passkey.last_method", From 315c89d2297afc134909b394ccbd40f7838fb716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:03:51 +0100 Subject: [PATCH 22/26] fix(targets): classify a weight with the height its band was built from The child-band change left the classification dividing by the record's height where it had checked the adult-band height, which the type checker refused. Both now read the same value. --- src/lib/targets/vitals-builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 9bed9428a..a734adf21 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -194,7 +194,7 @@ export function buildVitalTargets({ weightTargetOverride ?? (whoHeightCm ? getWeightRange(whoHeightCm) : null); let weightClassification: TargetItem["classification"] = null; if (latestByType.WEIGHT != null && whoHeightCm) { - const heightM = heightCm / 100; + const heightM = whoHeightCm / 100; const classification = classifyBMI( latestByType.WEIGHT / (heightM * heightM), ); From 2af0751b69a845572ae8ec2ebb47cf65f798082a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:03:51 +0100 Subject: [PATCH 23/26] test: type the new fixtures instead of casting them to never The fixtures added with these fixes switched the type checker off with `as never`, which the realism ratchet refuses to see grow. They now carry their types, three webhook mocks needed no cast at all, and the lowered counts, with the targets route's one fewer version stamp, are written back. --- src/__tests__/baselines/test-as-never.json | 4 ++-- src/__tests__/baselines/version-stamp-comments.json | 4 ++-- .../measurements/bulk-delete/__tests__/route.test.ts | 6 ++---- .../api/onboarding/complete/__tests__/route.test.ts | 2 +- src/app/api/telegram/webhook/__tests__/route.test.ts | 8 ++++---- .../__tests__/measurement-list-glucose-unit.test.tsx | 12 ++++++++++-- src/lib/ai/__tests__/provider.test.ts | 2 +- src/lib/ai/__tests__/resolve-chain.test.ts | 9 +++++---- .../__tests__/enrollment-proof-second-factor.test.ts | 5 +++-- src/lib/auth/__tests__/profile-update.test.ts | 2 +- 10 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index ef945bea3..4786f3b8b 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 523, - "total": 5459, + "total": 5457, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -236,7 +236,7 @@ "app/api/sleep/rhythm/__tests__/route.test.ts": 6, "app/api/sync/changes/__tests__/module-gate.test.ts": 7, "app/api/sync/state/__tests__/route.test.ts": 8, - "app/api/telegram/webhook/__tests__/route.test.ts": 43, + "app/api/telegram/webhook/__tests__/route.test.ts": 41, "app/api/tokens/documents/__tests__/route.test.ts": 25, "app/api/tokens/measurements/__tests__/route.test.ts": 25, "app/api/tokens/workouts/__tests__/route.test.ts": 25, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 642303b6b..85cd2c071 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2884, - "total": 9191, + "total": 9190, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -355,7 +355,7 @@ "app/api/insights/rhythm-events/route.ts": 2, "app/api/insights/settings/route.ts": 1, "app/api/insights/targets/__tests__/route.test.ts": 1, - "app/api/insights/targets/route.ts": 2, + "app/api/insights/targets/route.ts": 1, "app/api/insights/weight-status/route.ts": 2, "app/api/integrations/healthkit/__tests__/route.test.ts": 4, "app/api/integrations/healthkit/route.ts": 3, diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index cadf4b3e0..839656912 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -158,10 +158,8 @@ describe("POST /api/measurements/bulk-delete", () => { it("leaves a computed score out of the selection it deletes", async () => { // The read is scoped like the write, so a computed row never reaches it. - vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ - count: 0, - } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([]); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 }); await POST(postReq({ ids: ["score-1"] })); diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts index 1c1f36b18..164f97dd2 100644 --- a/src/app/api/onboarding/complete/__tests__/route.test.ts +++ b/src/app/api/onboarding/complete/__tests__/route.test.ts @@ -94,7 +94,7 @@ describe("POST /api/onboarding/complete", () => { vi.mocked(getSession).mockResolvedValue({ ...SESSION_OK, user: { ...SESSION_OK.user, timezone: "Europe/Berlin" }, - } as never); + } as unknown as Awaited>); // 14 May picked at 00:30 in Berlin. const res = await POST(req({ dateOfBirth: "1960-05-13T23:30:00Z" })); expect(res.status).toBe(200); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index f9fa42f50..9a92baadf 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -479,7 +479,7 @@ describe("Telegram webhook — callback dispatch", () => { }); it("rejects 'taken' for an inactive medication and acks with the localised error", async () => { - vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); @@ -497,7 +497,7 @@ describe("Telegram webhook — callback dispatch", () => { }); it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => { - vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60"))); expect(res.status).toBe(200); expect(prisma.medication.update).not.toHaveBeenCalled(); @@ -652,7 +652,7 @@ describe("Telegram webhook — text-message dispatch", () => { }); it("/add with no active medications replies with the 'no active' message", async () => { - vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST( tgRequest({ update_id: 203, @@ -769,7 +769,7 @@ describe("Telegram webhook — text-message dispatch", () => { it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => { // The lookup by name finds nothing. - vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST( tgRequest({ diff --git a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx index a42afdca6..0eec0f5f2 100644 --- a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx @@ -61,6 +61,7 @@ vi.mock("@/hooks/use-auth", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { MeasurementList } from "../measurement-list"; +import type { Measurement } from "../list/list-model"; import type { UnitDisplay } from "@/hooks/use-unit-display"; import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose"; import { getUnitForType } from "@/lib/measurements/unit-map"; @@ -150,8 +151,15 @@ describe("the out-of-range sentence", () => { `${key} ${JSON.stringify(params)}`) as unknown as Parameters< typeof outOfRangeMessage >[2]; - const reading = (type: string, unit: string) => - ({ id: "m1", type, value: 1, unit, source: "MANUAL" }) as never; + const reading = (type: string, unit: string): Measurement => ({ + id: "m1", + type, + value: 1, + unit, + source: "MANUAL", + measuredAt: "2026-11-10T12:00:00.000Z", + notes: null, + }); const shows = (unit: string) => ({ rowDisplay: (m: { value: number }) => ({ value: m.value, unit }), }); diff --git a/src/lib/ai/__tests__/provider.test.ts b/src/lib/ai/__tests__/provider.test.ts index 46a0ad60e..ea5ff3e40 100644 --- a/src/lib/ai/__tests__/provider.test.ts +++ b/src/lib/ai/__tests__/provider.test.ts @@ -362,7 +362,7 @@ describe("resolveProviderForTest — empty override resolves via the chain", () aiLocalKeyEncrypted: null, aiOpenaiKeyEncrypted: null, aiProviderChain: [{ providerType: "openai", priority: 1, enabled: true }], - } as never); + } as unknown as Awaited>); const provider = await resolveProviderForTest("user-123", {}); expect(provider.type).toBe("none"); diff --git a/src/lib/ai/__tests__/resolve-chain.test.ts b/src/lib/ai/__tests__/resolve-chain.test.ts index 183d72c71..57ed76485 100644 --- a/src/lib/ai/__tests__/resolve-chain.test.ts +++ b/src/lib/ai/__tests__/resolve-chain.test.ts @@ -230,9 +230,7 @@ describe("resolveProviderChain and the picked gateway", () => { /** Serve `chain` as the record's saved chain; Codex is not connected. */ async function resolveWith(chain: unknown) { - vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { - select?: Record; - }) => + const read = async (args: { select?: Record }) => args.select?.codexAccessTokenEncrypted ? { codexAccessTokenEncrypted: null, @@ -240,7 +238,10 @@ describe("resolveProviderChain and the picked gateway", () => { codexTokenExpiresAt: null, codexConnectionStatus: "disconnected", } - : { ...gatewayRow, aiProviderChain: chain }) as never); + : { ...gatewayRow, aiProviderChain: chain }; + vi.mocked(prisma.user.findUnique).mockImplementation( + read as unknown as typeof prisma.user.findUnique, + ); operator({ adminAiKeyEncrypted: null }); return (await resolveProviderChain("user-1")).map((c) => c.providerType); } diff --git a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts index 20ca150e3..c68778ded 100644 --- a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts +++ b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts @@ -9,6 +9,7 @@ * account only possession of a factor counts now; the account without one is * unchanged. */ +import type { NextRequest } from "next/server"; import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ @@ -252,10 +253,10 @@ describe("POST /api/auth/passkey/register-options", () => { vi.mocked(requireCookieAuth).mockResolvedValue({ user: stored(PLAIN), session: { id: "sess-1" }, - } as never); + } as unknown as Awaited>); vi.mocked(verifyPassword).mockResolvedValue(false); const res = await PASSKEY_REGISTER_OPTIONS( - passkeyReq({ method: "password", password: "typo" }) as never, + passkeyReq({ method: "password", password: "typo" }) as NextRequest, ); expect(res.status).toBe(401); expect((await res.json()).meta.errorCode).toBe("auth.reproof.failed"); diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index 9b5d83497..ad78f1475 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -102,7 +102,7 @@ describe("applyProfileUpdate date of birth", () => { // the zone resolver caches per account. vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Pacific/Tongatapu", - } as never); + } as unknown as Awaited>); await applyProfileUpdate("user-tonga", { dateOfBirth: "2012-05-13T20:30:00Z", }); From 8b6d9826ea6c4f351ab3ac9f8bd0f8ca08d5f8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:03:51 +0100 Subject: [PATCH 24/26] chore: drop stale references to the onboarding offer and old resolver The Playwright project list still named the deleted onboarding AI offer spec. The CoachPlan comments said the review writes `outcomeEncrypted` and surfaces it behind a flag; nothing writes or reads it now, and the column is marked internal. Comments that named `resolveProvider` as the resolver now name the chain, and the function itself says it is test-only. --- playwright.config.ts | 7 ------- prisma/schema.prisma | 11 ++++++----- src/components/settings/ai/shared.ts | 4 ++-- src/lib/ai/mock-client.ts | 4 ++-- src/lib/ai/provider.ts | 4 ++-- src/lib/ai/vision-capability.ts | 4 ++-- 6 files changed, 14 insertions(+), 20 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index c046e2d21..be75f3589 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -279,13 +279,6 @@ export default defineConfig({ "setup-flow-a11y.spec.ts", "setup-flow-connect.spec.ts", "setup-flow-locale-overflow.spec.ts", - // The shared-provider offer journey states INSTANCE-wide facts to - // reason about: it writes the operator key into `app_settings` and - // replaces every `provider_health` row for both shared tags, then - // puts both back. Serial mode only orders tests inside one file, so - // a second project would hold and clear that state underneath the - // first — and underneath any other spec that reads AI availability. - "onboarding-ai-offer.spec.ts", // The delegated-writes journey invites, accepts and revokes between // its own two accounts. A second project would send a second invitation // between the same pair while the first is live, and meet the 409. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 80f3d5962..d5943dc37 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7240,8 +7240,8 @@ model CoachFact { // `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration // purely additive): proposed | active | met | abandoned | review_due | reviewed. // The last two are the v1.22 (W9, C2) n-of-1 experiment lifecycle: an -// "experiment" is just a CoachPlan with a reviewDate; when the review fires and -// the read-back is generated, `outcomeEncrypted` carries the grounded result. +// "experiment" is just a CoachPlan with a reviewDate; the review only moves the +// status. `outcomeEncrypted` is no longer written or read (see the column). model CoachPlan { id String @id @default(cuid()) userId String @map("user_id") @@ -7260,9 +7260,10 @@ model CoachPlan { status String @default("proposed") // Optional review checkpoint the Coach can recall ("how did this go by …"). reviewDate DateTime? @map("review_date") - // v1.22 (W9, C2) — the n-of-1 read-back prose, grounded in the user's own - // before/after numbers and encrypted at rest like the cue/action. Written - // once when an experiment's review is processed; surfaced behind a flag. + // v1.22 (W9, C2) — the n-of-1 read-back prose an experiment's review used + // to write, encrypted at rest like the cue/action. Nothing writes or reads + // it now; older rows stay, rotated with the key and carried by the backup. + /// @internal: kept for rows written before the review stopped writing it. outcomeEncrypted Bytes? @map("outcome_encrypted") // Provenance — nullable so a future manual-entry surface can leave it null. sourceConversationId String? @map("source_conversation_id") diff --git a/src/components/settings/ai/shared.ts b/src/components/settings/ai/shared.ts index 02e763029..da8255efa 100644 --- a/src/components/settings/ai/shared.ts +++ b/src/components/settings/ai/shared.ts @@ -127,8 +127,8 @@ export const LOCAL_MODEL_PRESETS = [ export const CUSTOM_MODEL_SENTINEL = "__custom__"; /** - * Map UI provider tag → the `aiProvider` enum understood by the - * legacy single-result `resolveProvider()` resolver. `codex` and + * Map UI provider tag → the stored `aiProvider` enum, the provider picked + * under Settings, AI. `codex` and * `admin-openai` aren't user-level provider columns (they're handled * by Codex OAuth + admin-key fallback), so they map to null and the * legacy column is left blank. diff --git a/src/lib/ai/mock-client.ts b/src/lib/ai/mock-client.ts index 1b596662b..59e61a9e5 100644 --- a/src/lib/ai/mock-client.ts +++ b/src/lib/ai/mock-client.ts @@ -18,8 +18,8 @@ import { reasoningTitleOf } from "./reasoning/support"; * never hit the network. * * The mock is exported from `src/lib/ai/` so any test can import it, - * but it is NOT registered in `resolveProvider()` — the production - * path must never reach it. + * but no provider resolver returns it: the production path must never + * reach it. */ export interface MockAIProviderOptions { /** Provider tag returned in `CompletionResult.providerType`. Defaults to "local". */ diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 11b5b90b0..4f55ed9c2 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -711,7 +711,7 @@ async function resolveAdminCodexProvider(): Promise { } /** - * Resolve the AI provider for a given user. + * Test-only, ignoring the chain's switches: use `resolveProviderChain`. * * Priority: * 1. User selected ANTHROPIC / LOCAL with valid creds → that provider. @@ -840,7 +840,7 @@ export async function resolveProviderChain( aiResponseTimeoutSeconds: true, }, }); - // See `resolveProvider`: every instance carries the record's settings. + // Every instance carries the record's timeout and reasoning effort. const bind = (provider: AIProvider): AIProvider => bindReasoningEffort( bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds), diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index 2617f2f1b..9b8be711d 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -66,8 +66,8 @@ function openAIFamilySupportsVision(model: string): boolean { /** * Logical provider tags the OCR path reasons about. Mirrors the - * `ProviderChainType` vocabulary plus the legacy `admin-key` single-provider - * tag `resolveProvider`'s admin fallback returns. + * `ProviderChainType` vocabulary plus the legacy `admin-key` tag the + * operator-key client carries. */ export type VisionProviderType = | "anthropic" From 5b8543f47151a3aaab9263a55634958c55487c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:05:37 +0100 Subject: [PATCH 25/26] fix(settings): keep the Coach entry for a managed record The Settings Coach entry now follows whether the Coach can answer, as the sidebar does, with Settings, AI keeping the stored memory in reach. A managed record has no Settings, AI, so while its Coach could not answer yet (no operator key, no consent) the guardian lost the only way to the record's Coach data scope and its switch. For a managed record the entry follows the module switch alone again. --- .../coach-entry-follows-capability.test.tsx | 27 +++++++++++++++++-- src/components/settings/settings-shell.tsx | 16 ++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx index c30b49e8c..79d0eef04 100644 --- a/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx +++ b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx @@ -15,7 +15,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -const h = vi.hoisted(() => ({ coachAvailable: true })); +const h = vi.hoisted(() => ({ + coachAvailable: true, + active: null as Record | null, +})); vi.mock("next/navigation", () => ({ usePathname: () => "/settings/ai", @@ -24,7 +27,12 @@ vi.mock("next/navigation", () => ({ vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { id: "u1", role: "USER", modules: { coach: true } }, + user: { + id: "u1", + role: "USER", + modules: { coach: true }, + accountAccess: { active: h.active }, + }, isAuthenticated: true, isLoading: false, refetch: vi.fn(), @@ -68,6 +76,7 @@ function render(node: React.ReactNode) { describe("the Coach entry in Settings", () => { beforeEach(() => { h.coachAvailable = true; + h.active = null; }); it("is listed while the Coach can answer, and the memory lives there", () => { @@ -86,4 +95,18 @@ describe("the Coach entry in Settings", () => { ).not.toContain('href="/settings/coach"'); expect(render()).toContain('data-slot="stored-coach-memory"'); }); + + it("stays for a managed record whose Coach cannot answer yet", () => { + // A record has no Settings, AI: the guardian reaches the record's Coach + // data scope and switch through this entry only. + h.coachAvailable = false; + h.active = { + recordKind: "managed", + level: "manage", + manageableDomains: [], + }; + expect( + render(body), + ).toContain('href="/settings/coach"'); + }); }); diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index a2bcb5185..8d6924f5c 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -356,6 +356,20 @@ function deriveActiveSlug( return isSettingsSectionSlug(candidate) ? candidate : "account"; } +/** + * The module map the section list reads, the sidebar's. A managed record has + * no Settings, AI to list its Coach settings (its data scope, its switch), so + * there the Coach entry follows the module switch alone, not whether the + * record's Coach can answer yet. + */ +function useSectionModules() { + const { user } = useAuth(); + const navModules = useNavModules(); + return user?.accountAccess?.active?.recordKind === "managed" + ? user.modules + : navModules; +} + export function SettingsShell({ active, heading, @@ -391,7 +405,7 @@ export function SettingsShell({ // effect (which the lint rule flags as a cascading render), and no risk of // the two passes disagreeing. const hydrated = useMounted(); - const modules = useNavModules(); + const modules = useSectionModules(); const activeRecord = user?.accountAccess?.active ?? null; const allVisibleSections = SETTINGS_SECTIONS.filter( (section) => From bde455e3be971b436f4625e9c47cbc6bd777dee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:06:18 +0100 Subject: [PATCH 26/26] docs(api): regenerate the contract Carries the targets read at read level, the two new error codes (`customMetric.name_taken`, `auth.mfa.already_active`) and the notification categories' description without its em dash. --- docs/api/openapi.yaml | 62 +++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 443b3d55a..7dbc7be8a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13677,9 +13677,9 @@ paths: and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the - next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and - glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a - tile whose data does not exist is simply absent. Cookie or Bearer auth." + next read. Delegable at READ level over the whole record, like the dashboard snapshot: the walk spans vitals, + sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. + Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth." responses: "200": description: The target tiles and their page summary. @@ -39700,8 +39700,8 @@ components: anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, - auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, - auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.admin.required, auth.already_authenticated, auth.mfa.already_active, auth.mfa.code_invalid, + auth.missing, auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists, @@ -39714,30 +39714,30 @@ components: coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle — - cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, - cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, - cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal, - cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; - dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, - dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — - demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, - documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, - documents.inbound.pdfRendererUnavailable, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; customMetric + — customMetric.name_taken; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, + cycle.feed.fertile_goal, cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected, + dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, + dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfRendererUnavailable, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -90608,8 +90608,8 @@ components: type: array items: type: string - description: The event categories this account is shown a switch for, in declaration order — the list a client renders - rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it. + description: "The event categories this account is shown a switch for, in declaration order: the list a client renders + rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it." eventDefaults: type: object propertyNames: