From 1a7050d95d8d85612e2121b716de19028a78a72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:25:50 +0200 Subject: [PATCH 01/13] fix(backup): bring every relation link back from a portable restore A portable file carries no id for a medication or a lab result, so the restore mints fresh ones and the links that named the old ids were dropped as having no end. Carry the source id as ref on both entries and re-point the links through an old-to-new map on restore. --- src/lib/export/full-backup-payload.ts | 4 +- src/lib/export/records-backup.ts | 5 + src/lib/export/relation-links-backup.ts | 39 ++- src/lib/export/restore-backup.ts | 19 ++ src/lib/validations/backup.ts | 6 + .../relation-links-portable-restore.test.ts | 229 ++++++++++++++++++ 6 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 tests/integration/relation-links-portable-restore.test.ts diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5abd26b7d..2879e98f9 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -1094,7 +1094,9 @@ export async function buildFullBackupPayload( externalId: m.externalId, updatedAt: m.updatedAt.toISOString(), } - : {}), + : // No id: a portable restore mints a fresh one. The relation links + // still name this drug, so the file says which row they mean. + { ref: m.id }), name: m.name, dose: m.dose, active: m.active, diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index f2ca65bd2..239d3e5a3 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -83,6 +83,8 @@ function readForExport( export interface LabResultBackupEntry { /** Present in canonical DR payloads so rows keep a stable identity. */ id?: string; + /** The source row's id in a portable payload, for the relation links. */ + ref?: string; panel: string | null; analyte: string; value: number | null; @@ -455,6 +457,9 @@ export async function buildRecordsBackupSection( note: null, } : { + // No id: a portable restore mints a fresh one. The relation links + // still name this row, so the file says which row they mean. + ref: r.id, note: readForExport( r.noteEncrypted, "lab note", diff --git a/src/lib/export/relation-links-backup.ts b/src/lib/export/relation-links-backup.ts index e9a0f91c4..ed9ab58ce 100644 --- a/src/lib/export/relation-links-backup.ts +++ b/src/lib/export/relation-links-backup.ts @@ -244,6 +244,19 @@ function placeable( return kept; } +/** + * The id each medication and lab result had in the file, to the id the + * restore gave it. A portable file carries neither row's id (a restore there + * mints fresh ones), only a `ref` naming the source row, so a link that names + * the source id is re-pointed through this map. An id the map does not hold is + * used as it is: a disaster-recovery file keeps its ids, and a hand-edited one + * falls through to the placement check below. + */ +export interface RelationLinkSourceIds { + medications: ReadonlyMap; + labResults: ReadonlyMap; +} + /** * Re-create the five link tables. * @@ -261,7 +274,13 @@ export async function restoreRelationLinksData( ownerId: string, payload: RelationLinksBackupSection, skips: RestoreSkipLog, + sourceIds: RelationLinkSourceIds = { + medications: new Map(), + labResults: new Map(), + }, ): Promise { + const medication = (id: string) => sourceIds.medications.get(id) ?? id; + const labResult = (id: string) => sourceIds.labResults.get(id) ?? id; const owned = { where: { userId: ownerId } }; const [c1, c2, c3, c4, c5] = await Promise.all([ tx.labResultDocumentLink.deleteMany(owned), @@ -278,7 +297,10 @@ export async function restoreRelationLinksData( }); const labDocs = placeable( - payload.labResultDocumentLinks, + payload.labResultDocumentLinks.map((r) => ({ + ...r, + labResultId: labResult(r.labResultId), + })), [ [(r) => r.labResultId, written.labResults], [(r) => r.documentId, written.documents], @@ -297,7 +319,10 @@ export async function restoreRelationLinksData( } const encounterMeds = placeable( - payload.encounterMedicationLinks, + payload.encounterMedicationLinks.map((r) => ({ + ...r, + medicationId: medication(r.medicationId), + })), [ [(r) => r.encounterId, written.encounters], [(r) => r.medicationId, written.medications], @@ -335,7 +360,10 @@ export async function restoreRelationLinksData( } const medDocs = placeable( - payload.medicationDocumentLinks, + payload.medicationDocumentLinks.map((r) => ({ + ...r, + medicationId: medication(r.medicationId), + })), [ [(r) => r.medicationId, written.medications], [(r) => r.documentId, written.documents], @@ -354,7 +382,10 @@ export async function restoreRelationLinksData( } const medConditions = placeable( - payload.medicationConditionLinks, + payload.medicationConditionLinks.map((r) => ({ + ...r, + medicationId: medication(r.medicationId), + })), [ [(r) => r.medicationId, written.medications], [(r) => r.episodeId, written.episodes], diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 143689656..c860002cf 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -1158,6 +1158,12 @@ export async function restoreBackup( const medByName = new Map(); const restoredMedicationIds = new Set(); + // The id each medication had in the source account, to the id it got + // here. A disaster-recovery file keeps the id, so this is the + // identity there; a portable file names the source row only through + // `ref`, and the relation links (written last) are re-pointed through + // this map instead of being dropped as "end is gone". + const medicationIdBySourceId = new Map(); // The efficacy targets cannot ride inside the medication create // beside the schedules and the packs: `biomarkerId` is a foreign key // into a catalogue this transaction has not restored yet. They are @@ -1454,6 +1460,10 @@ export async function restoreBackup( }, }); restoredMedicationIds.add(created.id); + const medicationSourceId = m.id ?? m.ref; + if (medicationSourceId) { + medicationIdBySourceId.set(medicationSourceId, created.id); + } if (!medByName.has(m.name)) medByName.set(m.name, created.id); // v1.39.4 — the clinical category lives in its own table keyed on the // medication id, so it is written after the row exists and inside the @@ -2172,6 +2182,8 @@ export async function restoreBackup( // `committedRecordId` names the lab result it was committed to, and // that reference is resolved against the rows this loop writes. const restoredLabResultIds = new Set(); + // Same arrangement as `medicationIdBySourceId`, for the lab results. + const labResultIdBySourceId = new Map(); // What each drug was supposed to move, written now that both ends // exist. The drugs came back several hundred lines up and the // analytes only just now, and one of those two had to move for a @@ -2280,6 +2292,9 @@ export async function restoreBackup( }, }); restoredLabResultIds.add(createdLab.id); + const labSourceId = lab.id ?? lab.ref; + if (labSourceId) + labResultIdBySourceId.set(labSourceId, createdLab.id); } reportSection("illness"); @@ -2683,6 +2698,10 @@ export async function restoreBackup( ownerId, payload, skips, + { + medications: medicationIdBySourceId, + labResults: labResultIdBySourceId, + }, ); // The Coach's threads, turns and document links. AFTER the diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index bb6ad4f24..46f816c19 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -392,6 +392,10 @@ const customMedicationCategorySchema = z const medicationSchema = z .object({ id: z.string().min(1).optional(), + // The source row's id in a portable file, which carries no `id`: the + // relation links name the medication by it, and the restore re-points + // them at the row it writes (`relation-links-backup.ts`). + ref: z.string().min(1).optional(), name: z.string().min(1), dose: z.string(), treatmentClass: z.enum(MedicationCategory).optional(), @@ -1126,6 +1130,8 @@ const appSettingsBackupSchema = z const labResultBackupSchema = z .object({ id: z.string().min(1).optional(), + // The source row's id in a portable file, as on a medication. + ref: z.string().min(1).optional(), panel: z.string().nullable().optional(), analyte: z.string().min(1), value: z.number().nullable().optional(), diff --git a/tests/integration/relation-links-portable-restore.test.ts b/tests/integration/relation-links-portable-restore.test.ts new file mode 100644 index 000000000..308369684 --- /dev/null +++ b/tests/integration/relation-links-portable-restore.test.ts @@ -0,0 +1,229 @@ +/** + * The five relation link tables survive a PORTABLE export and restore. + * + * A portable file carries no row id for a medication or a lab result: the + * restore mints fresh ones, because the file may land on another instance or + * another account where the original ids mean nothing. The link tables name + * both of their ends by id, so a link that rode the file under the OLD id of + * either end used to point at nothing after the restore, and the restore's + * own placement check dropped it as "end is gone". Four of the five tables + * have a medication or a lab result at one end; only the visit to dose link + * came back. + * + * Driven through the real restore route. A portable file with documents is + * refused (no document bytes, see `backup-portable-file-restore.test.ts`), so + * the file restored here is the portable payload with the document entries + * taken from the disaster-recovery build of the same account: everything else + * (medications, lab results, links) is the portable bytes exactly as the + * export writes them. + * + * Mutation check: drop the `exportRef` from the portable medication or lab + * result entry and the matching link count goes to zero. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { linkTargets } from "@/lib/links"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seed() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "portable-links-owner", + email: "portable-links-owner@example.test", + role: "ADMIN", + }, + }); + const userId = owner.id; + const session = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const encounter = await prisma.encounter.create({ + data: { + userId, + occurredAt: new Date("2026-07-01T09:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + }, + }); + const document = await prisma.inboundDocument.create({ + data: { + userId, + kind: "LAB_RESULT", + title: "Thyroid panel", + mimeType: "application/pdf", + byteSize: 12, + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + documentDate: new Date("2026-06-30T00:00:00.000Z"), + }, + }); + const biomarker = await prisma.biomarker.create({ + data: { userId, name: "TSH", unit: "mU/L" }, + }); + const labResult = await prisma.labResult.create({ + data: { + userId, + biomarkerId: biomarker.id, + analyte: "TSH", + value: 2.1, + unit: "mU/L", + takenAt: new Date("2026-06-30T09:00:00.000Z"), + }, + }); + const episode = await prisma.illnessEpisode.create({ + data: { + userId, + label: "Thyroid", + type: "CHRONIC", + onsetAt: new Date("2026-01-20T00:00:00.000Z"), + }, + }); + const medication = await prisma.medication.create({ + data: { userId, name: "Levothyroxine", dose: "50 µg" }, + }); + // A second drug with no links, so a restore that bound every link to the + // first medication it found would be told apart from one that maps by key. + await prisma.medication.create({ + data: { userId, name: "Vitamin D", dose: "1000 IU" }, + }); + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + vaccineName: "Tdap", + }, + }); + + const link = ( + sourceKind: Parameters[1]["sourceKind"], + sourceId: string, + targetKind: Parameters[1]["targetKind"], + targetId: string, + ) => + linkTargets(prisma, { + userId, + sourceKind, + sourceId, + targetKind, + targetIds: [targetId], + }); + await link("labResult", labResult.id, "document", document.id); + await link("encounter", encounter.id, "medication", medication.id); + await link("encounter", encounter.id, "vaccination", vaccination.id); + await link("medication", medication.id, "document", document.id); + await link("medication", medication.id, "conditionEpisode", episode.id); + + return { userId, medication, labResult }; +} + +describe("a portable restore of the five relation link tables", () => { + it("brings every link back, bound to the records the restore wrote", async () => { + const prisma = getPrismaClient(); + const { userId, medication, labResult } = await seed(); + + const portable = parseBackupPayload( + JSON.stringify((await buildFullBackupPayload(prisma, userId)).payload), + ); + const dr = parseBackupPayload( + JSON.stringify( + ( + await buildFullBackupPayload(prisma, userId, { + purpose: "disaster-recovery", + }) + ).payload, + ), + ); + // The premise: the portable file names no medication or lab result id. + expect(portable.medications.every((m) => m.id === undefined)).toBe(true); + expect(portable.labResults.every((l) => l.id === undefined)).toBe(true); + + const file = parseBackupPayload( + JSON.stringify({ ...portable, documents: dr.documents }), + ); + const backup = await prisma.dataBackup.create({ + data: { + userId, + type: "PORTABLE_LINKS_RESTORE", + data: encrypt(JSON.stringify(file)), + }, + }); + + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, await response.clone().text()).toBe(200); + + // The ends were re-minted, so the old ids are gone. + const restoredMed = await prisma.medication.findFirstOrThrow({ + where: { userId, name: "Levothyroxine" }, + }); + const restoredLab = await prisma.labResult.findFirstOrThrow({ + where: { userId, analyte: "TSH" }, + }); + expect(restoredMed.id).not.toBe(medication.id); + expect(restoredLab.id).not.toBe(labResult.id); + + const where = { userId }; + const [labDocs, encMeds, encDoses, medDocs, medConds] = await Promise.all([ + prisma.labResultDocumentLink.findMany({ where }), + prisma.encounterMedicationLink.findMany({ where }), + prisma.encounterVaccinationLink.findMany({ where }), + prisma.medicationDocumentLink.findMany({ where }), + prisma.medicationConditionLink.findMany({ where }), + ]); + expect(labDocs.map((r) => r.labResultId)).toEqual([restoredLab.id]); + expect(encMeds.map((r) => r.medicationId)).toEqual([restoredMed.id]); + expect(encDoses).toHaveLength(1); + expect(medDocs.map((r) => r.medicationId)).toEqual([restoredMed.id]); + expect(medConds.map((r) => r.medicationId)).toEqual([restoredMed.id]); + }); +}); From c777819b337ad4fdf9608299f9e12e01679a9894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:28:07 +0200 Subject: [PATCH 02/13] fix(integrations): bound the Dawarich and Nightscout answers while reading --- .../__tests__/client-body-cap.test.ts | 42 +++++++++++++++++++ src/lib/dawarich/client.ts | 19 ++++++++- src/lib/nightscout/__tests__/client.test.ts | 30 +++++++++++-- src/lib/nightscout/client.ts | 10 ++++- 4 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 src/lib/dawarich/__tests__/client-body-cap.test.ts diff --git a/src/lib/dawarich/__tests__/client-body-cap.test.ts b/src/lib/dawarich/__tests__/client-body-cap.test.ts new file mode 100644 index 000000000..19c9fe2e4 --- /dev/null +++ b/src/lib/dawarich/__tests__/client-body-cap.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetchMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeFetch: safeFetchMock, +})); + +import { DawarichApiError, probeDawarich } from "../client"; + +const CONN = { baseUrl: "https://dawarich.example.com", apiKey: "key" }; + +beforeEach(() => { + safeFetchMock.mockReset(); +}); + +describe("reading a Dawarich answer", () => { + it("parses an honest answer", async () => { + safeFetchMock.mockResolvedValue( + new Response(JSON.stringify({ user: { id: 1 } }), { status: 200 }), + ); + await expect(probeDawarich(CONN)).resolves.toBeUndefined(); + }); + + it("stops reading an answer that runs past the cap", async () => { + // Streamed without a length, so only the running count can catch it. + const chunk = new Uint8Array(1024 * 1024).fill(32); + let sent = 0; + const body = new ReadableStream({ + pull(controller) { + sent += 1; + if (sent > 64) controller.close(); + else controller.enqueue(chunk); + }, + }); + safeFetchMock.mockResolvedValue(new Response(body, { status: 200 })); + const failure = await probeDawarich(CONN).catch((e: unknown) => e); + expect(failure).toBeInstanceOf(DawarichApiError); + expect((failure as Error).message).toMatch(/oversized/); + expect(sent).toBeLessThan(64); + }); +}); diff --git a/src/lib/dawarich/client.ts b/src/lib/dawarich/client.ts index e90f5d06b..68fe4e62b 100644 --- a/src/lib/dawarich/client.ts +++ b/src/lib/dawarich/client.ts @@ -5,7 +5,9 @@ * reads four endpoints, all with `Authorization: Bearer `: * * GET /api/v1/timeline?start_at&end_at&distance_unit=km - * one entry per local day (the instance's own zone) with a `summary`: + * one entry per day in the Dawarich account's own zone (its timezone + * setting, the instance's `TIME_ZONE` by default; the request cannot + * name one), summing only what lies inside the window, with a `summary`: * `total_distance`, `places_visited`, `mode_distances.{driving,train}` * in km to one decimal. At most 31 days per request. * GET /api/v1/visits?start_at&end_at @@ -26,6 +28,7 @@ * `DAWARICH_PRIVATE_ORIGINS`. The policy is re-read on every call, so taking * an origin off the list stops the next request. */ +import { readBodyCapped } from "@/lib/http/read-capped"; import { SafeFetchError, safeFetch } from "@/lib/safe-fetch"; import { @@ -35,6 +38,14 @@ import { const DEFAULT_TIMEOUT_MS = 20_000; +/** + * The most any one answer may hold. A month of timeline with its entries and + * a year of residency stay far below it; the cap is what keeps an instance + * that streams without end from holding the worker's memory until the + * timeout. + */ +const MAX_BODY_BYTES = 16 * 1024 * 1024; + /** Dawarich refuses a timeline window longer than 31 days. */ export const TIMELINE_MAX_DAYS = 31; @@ -118,8 +129,12 @@ async function dawarichGet( if (res.status < 200 || res.status >= 300) { throw new DawarichApiError(`Dawarich responded ${res.status}`, res.status); } + const read = await readBodyCapped(res, MAX_BODY_BYTES); + if (!read.ok) { + throw new DawarichApiError("Dawarich returned an oversized body", null); + } try { - return await res.json(); + return JSON.parse(read.text) as unknown; } catch { throw new DawarichApiError("Dawarich returned a non-JSON body", null); } diff --git a/src/lib/nightscout/__tests__/client.test.ts b/src/lib/nightscout/__tests__/client.test.ts index 7005a56e3..1a08b9004 100644 --- a/src/lib/nightscout/__tests__/client.test.ts +++ b/src/lib/nightscout/__tests__/client.test.ts @@ -18,11 +18,10 @@ import { } from "../client"; function jsonResponse(status: number, body: unknown): Response { - return { + return new Response(JSON.stringify(body), { status, - ok: status >= 200 && status < 300, - json: async () => body, - } as unknown as Response; + headers: { "content-type": "application/json" }, + }); } const SAMPLE: NightscoutSgvEntry[] = [ @@ -295,4 +294,27 @@ describe("fetchSgvEntries — SSRF gating", () => { expect(entries).toHaveLength(2); expect(entries[0]!.sgv).toBe(112); }); + + it("stops reading an answer that runs past the cap", async () => { + // Streamed without a length, so only the running count can catch it. + const chunk = new Uint8Array(1024 * 1024).fill(32); + let sent = 0; + const body = new ReadableStream({ + pull(controller) { + sent += 1; + if (sent > 64) controller.close(); + else controller.enqueue(chunk); + }, + }); + safeFetchMock.mockResolvedValue(new Response(body, { status: 200 })); + await expect( + fetchSgvEntries({ + baseUrl: "https://ns.example.com", + token: "", + count: 10, + allowPrivateHost: false, + }), + ).rejects.toThrow(/oversized/); + expect(sent).toBeLessThan(64); + }); }); diff --git a/src/lib/nightscout/client.ts b/src/lib/nightscout/client.ts index e41be4bcd..acaf4454b 100644 --- a/src/lib/nightscout/client.ts +++ b/src/lib/nightscout/client.ts @@ -23,6 +23,7 @@ import { createHash } from "node:crypto"; import type { MeasurementType } from "@/generated/prisma/client"; +import { readBodyCapped } from "@/lib/http/read-capped"; import { safeFetch } from "@/lib/safe-fetch"; import { configuredNightscoutPrivateOrigins, @@ -33,6 +34,7 @@ import { SafeFetchError } from "@/lib/safe-fetch"; /** Default per-request timeout for a Nightscout fetch. */ const NIGHTSCOUT_TIMEOUT_MS = 15_000; +const NIGHTSCOUT_MAX_BODY_BYTES = 8 * 1024 * 1024; /** Canonical glucose storage unit. Nightscout SGV is always mg/dL on the wire. */ export const NIGHTSCOUT_GLUCOSE_UNIT = "mg/dL" as const; @@ -259,9 +261,15 @@ export async function fetchSgvEntries( ); } + // Bounded while reading: `count` entries are a few hundred bytes each, so + // the cap is far above any honest answer and stops one that never ends. + const read = await readBodyCapped(res, NIGHTSCOUT_MAX_BODY_BYTES); + if (!read.ok) { + throw new NightscoutApiError("Nightscout returned an oversized body", null); + } let body: unknown; try { - body = await res.json(); + body = JSON.parse(read.text) as unknown; } catch { throw new NightscoutApiError("Nightscout returned a non-JSON body", null); } From e9a0b3d9599902b1da1559956a152d7a4ef309d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:28:51 +0200 Subject: [PATCH 03/13] fix(dawarich): keep a boundary fragment from overwriting a complete day Dawarich groups the timeline by the day in its own account zone and sums only what lies inside the window. Reach a whole day past both ends of each request and keep only the chunk's own dates, so the cut edge days are discarded and chunks never overwrite each other. --- src/lib/dawarich/sync.ts | 69 ++++++++++++++++---- tests/integration/dawarich-context.test.ts | 76 ++++++++++++++++++++++ 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/src/lib/dawarich/sync.ts b/src/lib/dawarich/sync.ts index b1d6712ef..98f4e458e 100644 --- a/src/lib/dawarich/sync.ts +++ b/src/lib/dawarich/sync.ts @@ -46,8 +46,57 @@ const HOME_COUNTRY_WINDOW_DAYS = 365; /** How far before the range the visits request reaches. */ const VISIT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000; -/** Days per timeline request, inside Dawarich's 31-day limit. */ -const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 1; +/** + * Days kept per timeline request. Each request reaches one whole day past + * both ends of its chunk (see `timelineChunks`), so it spans the chunk plus + * two days, and a fall-back day is 25 hours long: chunk + 2 must stay under + * Dawarich's 31-day limit by more than an hour. + */ +const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 3; + +/** + * The timeline requests for `days`, each with the dates it is allowed to + * fill. + * + * Dawarich groups a timeline by the day in ITS user's zone (the timezone + * setting of the Dawarich account, `TIME_ZONE` by default), not by the zone + * the request names, and it only sums what lies inside the requested window. + * A window cut at the account's local midnight therefore answers the edge day + * in the instance's zone with a fragment of it whenever the two zones differ. + * Two rules keep a fragment out of the stored values: + * + * - each request reaches a whole day before and after its chunk, so every + * date of the chunk is covered end to end in any zone within a day of the + * account's; + * - only the chunk's own dates are kept from the answer, so the padded edge + * days (the fragments) are thrown away and no chunk can overwrite a date + * another chunk owns. + * + * The API offers no zone parameter and no per-track detail to regroup by, so + * the stored value for a date is the instance's day of that date. When the + * two zones agree, as on a typical self-host, that is exactly the account's + * local day. + */ +function timelineChunks( + days: readonly string[], + tz: string, +): Array<{ startAt: Date; endAt: Date; dates: Set }> { + const out: Array<{ startAt: Date; endAt: Date; dates: Set }> = []; + for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) { + const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS); + const { dayStart } = localDayWindow(shiftDateKey(chunk[0], -1), tz); + const { dayEnd } = localDayWindow( + shiftDateKey(chunk[chunk.length - 1], 1), + tz, + ); + out.push({ + startAt: dayStart, + endAt: new Date(dayEnd.getTime() - 1000), + dates: new Set(chunk), + }); + } + return out; +} export interface DawarichSyncResult { /** Days written this run (new or refreshed). */ @@ -137,16 +186,14 @@ export async function syncDawarichRange( let visits: Awaited> = []; let countries: DailyCountries | null = null; try { - for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) { - const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS); - const { dayStart } = localDayWindow(chunk[0], tz); - const { dayEnd } = localDayWindow(chunk[chunk.length - 1], tz); + for (const chunk of timelineChunks(days, tz)) { + const answered = await fetchTimeline( + connection, + chunk.startAt, + chunk.endAt, + ); timeline = timeline.concat( - await fetchTimeline( - connection, - dayStart, - new Date(dayEnd.getTime() - 1000), - ), + answered.filter((day) => chunk.dates.has(day.date)), ); } const rangeStart = localDayWindow(fromDate, tz).dayStart; diff --git a/tests/integration/dawarich-context.test.ts b/tests/integration/dawarich-context.test.ts index f91aba072..04a1e1855 100644 --- a/tests/integration/dawarich-context.test.ts +++ b/tests/integration/dawarich-context.test.ts @@ -134,6 +134,10 @@ function residency(year: number) { }; } +/** A timeline that answers from the window, set by the test that needs it. */ +let timelineAnswer: ((url: URL) => { status: number; body: unknown }) | null = + null; + function answer(req: IncomingMessage): { status: number; body: unknown } { const url = new URL(req.url ?? "/", "http://stub"); requests.push(url.pathname); @@ -144,6 +148,7 @@ function answer(req: IncomingMessage): { status: number; body: unknown } { case "/api/v1/users/me": return { status: 200, body: { email: "someone@example.test" } }; case "/api/v1/timeline": + if (timelineAnswer) return timelineAnswer(url); return { status: 200, body: { days: TIMELINE_DAYS } }; case "/api/v1/visits": return { status: 200, body: VISITS }; @@ -188,6 +193,7 @@ beforeEach(async () => { await truncateAllTables(prisma); cookieJar.clear(); requests.length = 0; + timelineAnswer = null; process.env.DAWARICH_PRIVATE_ORIGINS = origin; await prisma.user.create({ data: { @@ -516,3 +522,73 @@ describe("Disconnect", () => { expect(await storedDays()).toHaveLength(0); }); }); + +describe("Timeline days across a request boundary", () => { + /** + * The instance as Dawarich builds a timeline (`Timeline::DayAssembler`): + * days in the Dawarich account's own zone, here New York while the + * HealthLog account is in Berlin, and only what lies inside the requested + * window is summed, so a window cut at Berlin midnight answers the New + * York day it cuts with a fragment. Every New York day holds a 1 km trip + * at 02:00 and a 10 km trip at 20:00 (02:00 the next morning in Berlin), + * so a complete day is 11 km and a fragment 1 or 10. A window longer than + * 31 days is refused as the instance refuses it. + */ + const INSTANCE_TZ = "America/New_York"; + const nyDate = (ms: number) => + new Date(ms).toLocaleDateString("en-CA", { timeZone: INSTANCE_TZ }); + const nyInstant = (date: string, hour: number) => { + // New York is UTC-4 for the whole summer range this test reads. + return Date.parse(`${date}T${String(hour).padStart(2, "0")}:00:00-04:00`); + }; + function instanceTimeline(url: URL) { + const startMs = Date.parse(url.searchParams.get("start_at") ?? ""); + const endMs = Date.parse(url.searchParams.get("end_at") ?? ""); + if (endMs - startMs > 31 * 24 * 3_600_000) { + return { status: 400, body: { error: "range too large" } }; + } + const totals = new Map(); + const from = new Date(startMs - 2 * 86_400_000); + for (let d = from.getTime(); d <= endMs + 86_400_000; d += 86_400_000) { + const date = nyDate(d); + for (const [hour, km] of [ + [2, 1], + [20, 10], + ] as const) { + const at = nyInstant(date, hour); + if (at < startMs || at > endMs) continue; + totals.set(date, (totals.get(date) ?? 0) + km); + } + } + return { + status: 200, + body: { + days: [...totals].map(([date, km]) => ({ + date, + summary: { + total_distance: km, + places_visited: 0, + mode_distances: {}, + }, + })), + }, + }; + } + + it("never lets a boundary fragment overwrite a complete day", async () => { + timelineAnswer = instanceTimeline; + expect((await connect()).status).toBe(200); + await getPrismaClient().dawarichDay.deleteMany({}); + + const { syncDawarichRange } = await import("@/lib/dawarich/sync"); + // Sixty days: three timeline requests, two boundaries between them. + await syncDawarichRange(USER_ID, "2026-06-01", "2026-07-30"); + + const days = await storedDays(); + expect(days).toHaveLength(60); + const wrong = days + .filter((d) => d.totalDistanceM !== 11_000) + .map((d) => `${d.date}=${d.totalDistanceM}`); + expect(wrong).toEqual([]); + }); +}); From b99b70127d316a7d6153c248751a63fdca73bcec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:36:42 +0200 Subject: [PATCH 04/13] fix(anamnesis): one newest AUDIT-C entry, scored against the wording offered The stored language is the wording the read route offers the account; a request naming another wording, or a takenAt more than five minutes ahead, is refused. Latest, offerDue, the Coach block and the doctor report all read the newest entry whose answers open. The old one-word alcohol pattern gives way to an AUDIT-C entry in the Coach block, and the alcohol section lists it with a remove action. --- docs/api/openapi.yaml | 15 ++- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/ko.json | 9 +- messages/pl.json | 9 +- src/app/api/anamnesis/audit-c/route.ts | 33 ++++++- .../__tests__/audit-c-legacy-pattern.test.tsx | 73 +++++++++++++++ src/components/records/audit-c-panel.tsx | 68 +++++++++++++- .../profile-ai-section-gating.test.ts | 81 ++++++++++++++-- src/lib/ai/coach/about-me.ts | 17 ++-- src/lib/openapi/routes/life-circumstances.ts | 14 ++- src/lib/profile/audit-c-entries.ts | 93 +++++++++++++++++++ src/lib/profile/life-circumstances.ts | 72 ++++---------- tests/integration/life-circumstances.test.ts | 73 +++++++++++++++ 17 files changed, 515 insertions(+), 87 deletions(-) create mode 100644 src/components/records/__tests__/audit-c-legacy-pattern.test.tsx create mode 100644 src/lib/profile/audit-c-entries.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f735b0d4e..6c1255829 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -24630,8 +24630,11 @@ paths: tags: - Anamnesis summary: Record an AUDIT-C (v1.43) - description: Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored - result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour. + description: Scores the three items on the server. The entry is stored in the language of the wording the read route + offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent, + must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422 + `anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five + minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour. parameters: - *a6 requestBody: @@ -38941,6 +38944,9 @@ components: id: type: string minLength: 1 + ref: + type: string + minLength: 1 name: type: string minLength: 1 @@ -40523,6 +40529,9 @@ components: id: type: string minLength: 1 + ref: + type: string + minLength: 1 panel: anyOf: - type: string @@ -86403,6 +86412,7 @@ components: anyOf: - $ref: "#/components/schemas/AuditCResult" - type: "null" + description: The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only. history: type: array items: @@ -86426,6 +86436,7 @@ components: description: Newest first, at most 20. offerDue: type: boolean + description: No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older. instrument: type: object properties: diff --git a/messages/de.json b/messages/de.json index a9c1f83c5..d514020f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "Der Eintrag verschwindet aus deinem Verlauf. Das lässt sich hier nicht rückgängig machen.", "removedToast": "Eintrag entfernt", "removeError": "Der Eintrag konnte nicht entfernt werden.", - "historyEntry": "{date}: {score} von 12" + "historyEntry": "{date}: {score} von 12", + "legacyLabel": "Frühere Angabe: {value}", + "legacyHint": "Aus der kurzen Alkoholfrage vor diesem Fragebogen. Sobald ein Fragebogen-Ergebnis vorliegt, liest der Coach sie nicht mehr.", + "legacyRemove": "Frühere Angabe entfernen", + "legacyRemoveConfirmTitle": "Frühere Angabe entfernen?", + "legacyRemoveConfirmBody": "Die frühere Angabe verschwindet aus deinen Daten. Fragebogen-Ergebnisse bleiben.", + "legacyRemovedToast": "Frühere Angabe entfernt", + "legacyRemoveError": "Die frühere Angabe konnte nicht entfernt werden." }, "takeOver": { "conditionsButton": "In Zustandstagebuch übernehmen", diff --git a/messages/en.json b/messages/en.json index ca6175a86..84b69140a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "The entry leaves your history. This cannot be undone here.", "removedToast": "Entry removed", "removeError": "The entry could not be removed.", - "historyEntry": "{date}: {score} of 12" + "historyEntry": "{date}: {score} of 12", + "legacyLabel": "Earlier answer: {value}", + "legacyHint": "From the short alcohol question asked before this questionnaire. Once a questionnaire result is on file, the Coach no longer reads it.", + "legacyRemove": "Remove earlier answer", + "legacyRemoveConfirmTitle": "Remove the earlier answer?", + "legacyRemoveConfirmBody": "The earlier answer leaves your record. Questionnaire results stay.", + "legacyRemovedToast": "Earlier answer removed", + "legacyRemoveError": "The earlier answer could not be removed." }, "takeOver": { "conditionsButton": "Add to condition journal", diff --git a/messages/es.json b/messages/es.json index 8b39b0e3f..7f428795b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "La entrada sale de tu historial. Aquí no se puede deshacer.", "removedToast": "Entrada eliminada", "removeError": "No se pudo eliminar la entrada.", - "historyEntry": "{date}: {score} de 12" + "historyEntry": "{date}: {score} de 12", + "legacyLabel": "Respuesta anterior: {value}", + "legacyHint": "De la pregunta breve sobre alcohol anterior a este cuestionario. En cuanto hay un resultado del cuestionario, el Coach deja de leerla.", + "legacyRemove": "Eliminar respuesta anterior", + "legacyRemoveConfirmTitle": "¿Eliminar la respuesta anterior?", + "legacyRemoveConfirmBody": "La respuesta anterior sale de tu registro. Los resultados del cuestionario se mantienen.", + "legacyRemovedToast": "Respuesta anterior eliminada", + "legacyRemoveError": "No se pudo eliminar la respuesta anterior." }, "takeOver": { "conditionsButton": "Pasar al diario de afecciones", diff --git a/messages/fr.json b/messages/fr.json index 92e11b7cd..8f7f8f69f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "L'entrée quitte votre historique. Impossible d'annuler ici.", "removedToast": "Entrée supprimée", "removeError": "Impossible de supprimer l'entrée.", - "historyEntry": "{date} : {score} sur 12" + "historyEntry": "{date} : {score} sur 12", + "legacyLabel": "Réponse précédente : {value}", + "legacyHint": "Issue de la courte question sur l’alcool posée avant ce questionnaire. Dès qu’un résultat du questionnaire existe, le Coach ne la lit plus.", + "legacyRemove": "Supprimer la réponse précédente", + "legacyRemoveConfirmTitle": "Supprimer la réponse précédente ?", + "legacyRemoveConfirmBody": "La réponse précédente quitte votre dossier. Les résultats du questionnaire restent.", + "legacyRemovedToast": "Réponse précédente supprimée", + "legacyRemoveError": "La réponse précédente n’a pas pu être supprimée." }, "takeOver": { "conditionsButton": "Reprendre dans le journal des affections", diff --git a/messages/it.json b/messages/it.json index c25858eae..4e3f7b5b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "La voce esce dalla tua cronologia. Qui non si può annullare.", "removedToast": "Voce rimossa", "removeError": "Impossibile rimuovere la voce.", - "historyEntry": "{date}: {score} su 12" + "historyEntry": "{date}: {score} su 12", + "legacyLabel": "Risposta precedente: {value}", + "legacyHint": "Dalla breve domanda sull’alcol posta prima di questo questionario. Quando c’è un risultato del questionario, il Coach non la legge più.", + "legacyRemove": "Rimuovi risposta precedente", + "legacyRemoveConfirmTitle": "Rimuovere la risposta precedente?", + "legacyRemoveConfirmBody": "La risposta precedente esce dai tuoi dati. I risultati del questionario restano.", + "legacyRemovedToast": "Risposta precedente rimossa", + "legacyRemoveError": "Non è stato possibile rimuovere la risposta precedente." }, "takeOver": { "conditionsButton": "Riporta nel diario delle condizioni", diff --git a/messages/ko.json b/messages/ko.json index 56230ec63..95d537ce8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "기록이 내역에서 사라져요. 여기서는 되돌릴 수 없어요.", "removedToast": "기록을 삭제했어요", "removeError": "기록을 삭제하지 못했어요.", - "historyEntry": "{date}: 12점 중 {score}점" + "historyEntry": "{date}: 12점 중 {score}점", + "legacyLabel": "이전 답변: {value}", + "legacyHint": "이 설문 전에 묻던 짧은 음주 질문의 답변입니다. 설문 결과가 있으면 코치는 더 이상 이 답변을 읽지 않습니다.", + "legacyRemove": "이전 답변 삭제", + "legacyRemoveConfirmTitle": "이전 답변을 삭제할까요?", + "legacyRemoveConfirmBody": "이전 답변이 기록에서 삭제됩니다. 설문 결과는 그대로 남습니다.", + "legacyRemovedToast": "이전 답변을 삭제했습니다", + "legacyRemoveError": "이전 답변을 삭제하지 못했습니다." }, "takeOver": { "conditionsButton": "상태 일지로 옮기기", diff --git a/messages/pl.json b/messages/pl.json index 0a5590b85..5fe13ba60 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "Wpis zniknie z historii. Tutaj nie da się tego cofnąć.", "removedToast": "Wpis usunięty", "removeError": "Nie udało się usunąć wpisu.", - "historyEntry": "{date}: {score} z 12" + "historyEntry": "{date}: {score} z 12", + "legacyLabel": "Wcześniejsza odpowiedź: {value}", + "legacyHint": "Z krótkiego pytania o alkohol sprzed tego kwestionariusza. Gdy jest wynik kwestionariusza, Coach przestaje ją czytać.", + "legacyRemove": "Usuń wcześniejszą odpowiedź", + "legacyRemoveConfirmTitle": "Usunąć wcześniejszą odpowiedź?", + "legacyRemoveConfirmBody": "Wcześniejsza odpowiedź zniknie z Twoich danych. Wyniki kwestionariusza zostają.", + "legacyRemovedToast": "Wcześniejsza odpowiedź usunięta", + "legacyRemoveError": "Nie udało się usunąć wcześniejszej odpowiedzi." }, "takeOver": { "conditionsButton": "Przenieś do dziennika stanów", diff --git a/src/app/api/anamnesis/audit-c/route.ts b/src/app/api/anamnesis/audit-c/route.ts index fa979b285..cae7171d6 100644 --- a/src/app/api/anamnesis/audit-c/route.ts +++ b/src/app/api/anamnesis/audit-c/route.ts @@ -26,10 +26,14 @@ import { import { auditLog } from "@/lib/auth/audit"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; +import { auditCInstrument } from "@/lib/mental-health/audit-c"; import { createAuditC } from "@/lib/profile/life-circumstances"; import { checkRateLimit } from "@/lib/rate-limit"; import { auditCRequestSchema } from "@/lib/validations/life-circumstances"; +/** How far ahead of the server clock a `takenAt` may lie. */ +const AUDIT_C_CLOCK_SKEW_MS = 5 * 60 * 1000; + export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC)); async function postAuditC(request: NextRequest): Promise { @@ -54,11 +58,36 @@ async function postAuditC(request: NextRequest): Promise { } const input = parsed.data; + + // The cut-off follows the language of the wording that was answered, and + // the only wording this account is ever shown is the one the read route + // delivers for its language. The stored language is that one, decided + // here; a client naming another wording answered something this server + // did not offer (or the account language changed under an open form), and + // is refused rather than scored against the wrong table. + const offered = auditCInstrument(user.locale ?? "en").locale; + if ( + input.locale !== undefined && + auditCInstrument(input.locale).locale !== offered + ) { + return apiError("The wording answered is not the one offered", 422, { + errorCode: "anamnesis.auditC.invalid", + }); + } + // An entry dated in the future would hold the "newest" place for as long + // as the clock takes to reach it. A few minutes of device clock skew pass. + const takenAt = input.takenAt ? new Date(input.takenAt) : undefined; + if (takenAt && takenAt.getTime() > Date.now() + AUDIT_C_CLOCK_SKEW_MS) { + return apiError("takenAt lies in the future", 422, { + errorCode: "anamnesis.auditC.invalid", + }); + } + const { result, replayed } = await createAuditC(user.id, { items: input.items, - takenAt: input.takenAt ? new Date(input.takenAt) : undefined, + takenAt, tz: input.tz, - locale: input.locale ?? user.locale ?? "en", + locale: offered, source: input.source, externalId: input.externalId, }); diff --git a/src/components/records/__tests__/audit-c-legacy-pattern.test.tsx b/src/components/records/__tests__/audit-c-legacy-pattern.test.tsx new file mode 100644 index 000000000..89a74a2a8 --- /dev/null +++ b/src/components/records/__tests__/audit-c-legacy-pattern.test.tsx @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const state = vi.hoisted(() => ({ + facts: null as unknown, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: state.facts }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiDelete: vi.fn(), +})); +vi.mock("@/components/outcome/outcome-toast", () => ({ + toastWrittenOutcome: vi.fn(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { auditCInstrument } from "@/lib/mental-health/audit-c"; +import { AuditCPanel } from "../audit-c-panel"; + +function render() { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +beforeEach(() => { + state.facts = null; +}); + +describe("the old alcohol pattern in the alcohol section", () => { + it("lists the stored pattern with a way to remove it", () => { + state.facts = { + current: { + SMOKING_STATUS: null, + SHIFT_SCHEDULE: null, + ALCOHOL_PATTERN: { id: "fact-1", value: "WEEKLY" }, + }, + history: [], + }; + const html = render(); + expect(html).toContain('data-slot="audit-c-legacy-pattern"'); + expect(html).toContain("Earlier answer: Weekly"); + expect(html).toContain("audit-c-legacy-remove"); + }); + + it("shows nothing when there is no stored pattern", () => { + state.facts = { + current: { + SMOKING_STATUS: null, + SHIFT_SCHEDULE: null, + ALCOHOL_PATTERN: null, + }, + history: [], + }; + expect(render()).not.toContain("audit-c-legacy-pattern"); + }); +}); diff --git a/src/components/records/audit-c-panel.tsx b/src/components/records/audit-c-panel.tsx index 233d51e6e..4edc2a06c 100644 --- a/src/components/records/audit-c-panel.tsx +++ b/src/components/records/audit-c-panel.tsx @@ -10,7 +10,7 @@ * and its source are the server's too; nothing is scored here. */ import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -18,9 +18,10 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { apiDelete, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { HealthProfileFactsPayload } from "@/lib/profile/health-facts"; import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances"; import { queryKeys } from "@/lib/query-keys"; @@ -76,6 +77,34 @@ export function AuditCPanel({ const complete = answers.every((answer) => answer !== null); + // The one-word alcohol pattern asked before AUDIT-C. It still lives in the + // record, so it is shown here, where alcohol now lives, with a way out. + // The Coach stops reading it once an AUDIT-C entry exists. + const queryClient = useQueryClient(); + const facts = useQuery({ + queryKey: queryKeys.healthProfileFacts(), + queryFn: () => apiGet("/api/anamnesis/facts"), + }); + const legacy = facts.data?.current.ALCOHOL_PATTERN ?? null; + const removeLegacy = useMutation({ + mutationKey: queryKeys.healthProfileFacts(), + mutationFn: (id: string) => apiDelete(`/api/anamnesis/facts/${id}`), + onSuccess: () => { + toastWrittenOutcome( + "success", + t("records.life.auditC.legacyRemovedToast"), + ); + void queryClient.invalidateQueries({ + queryKey: queryKeys.healthProfileFacts(), + }); + onChanged(); + }, + onError: (error) => + showErrorToast(t("records.life.auditC.legacyRemoveError"), { + cause: error, + }), + }); + return (

{t("records.life.auditC.intro")}

@@ -154,6 +183,41 @@ export function AuditCPanel({
) : null} + {legacy?.value ? ( +
+
+

+ {t("records.life.auditC.legacyLabel", { + value: t( + `records.profileFacts.values.ALCOHOL_PATTERN.${legacy.value}`, + ), + })} +

+

+ {t("records.life.auditC.legacyHint")} +

+
+ removeLegacy.mutate(legacy.id)} + pending={removeLegacy.isPending} + disabled={removeLegacy.isPending} + variant="ghost" + size="icon" + className="text-destructive min-h-11 sm:min-h-9" + icon={} + slot="audit-c-legacy-remove" + /> +
+ ) : null} + {data.offerDue ? (

{t("records.life.auditC.offerDue")} diff --git a/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts b/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts index 2a3b575c1..e9c7dabf4 100644 --- a/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts +++ b/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ allergyFindMany: vi.fn(), familyFindMany: vi.fn(), factFindMany: vi.fn(), - auditFindFirst: vi.fn(), + auditFindMany: vi.fn(), decrypt: vi.fn(), })); @@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({ allergy: { findMany: mocks.allergyFindMany }, familyHistoryEntry: { findMany: mocks.familyFindMany }, healthProfileFactRevision: { findMany: mocks.factFindMany }, - mentalHealthAssessment: { findFirst: mocks.auditFindFirst }, + mentalHealthAssessment: { findMany: mocks.auditFindMany }, }, })); @@ -46,13 +46,15 @@ beforeEach(() => { mocks.allergyFindMany.mockResolvedValue([]); mocks.familyFindMany.mockResolvedValue([]); mocks.factFindMany.mockResolvedValue([]); - mocks.auditFindFirst.mockResolvedValue(null); + mocks.auditFindMany.mockResolvedValue([]); mocks.decrypt.mockImplementation((bytes: Uint8Array) => { if (bytes[0] === 1) return secret; if (bytes[0] === 2) return "Asthma"; if (bytes[0] === 3) return "FORMER"; if (bytes[0] === 4) return "OCCASIONAL"; if (bytes[0] === 5) return "ROTATING"; + if (bytes[0] === 6) return JSON.stringify({ schema: 1, items: [2, 2, 1] }); + if (bytes[0] === 7) throw new Error("unknown key id"); return "unknown"; }); }); @@ -204,6 +206,11 @@ describe("profile section access in AI prompt paths", () => { if (bytes[0] === 21) { return JSON.stringify({ schema: 1, goals: ["sleep"] }); } + if (bytes[0] === 4) return "OCCASIONAL"; + if (bytes[0] === 6) { + return JSON.stringify({ schema: 1, items: [2, 2, 1] }); + } + if (bytes[0] === 7) throw new Error("unknown key id"); return "unknown"; }); }); @@ -235,11 +242,16 @@ describe("profile section access in AI prompt paths", () => { }); it("gives AUDIT-C as a sum with its screening verdict under the alcohol switch only", async () => { - mocks.auditFindFirst.mockResolvedValue({ - totalScore: 5, - takenAt: new Date("2026-10-01T09:00:00.000Z"), - locale: "de", - }); + mocks.auditFindMany.mockResolvedValue([ + { + id: "audit-1", + totalScore: 5, + takenAt: new Date("2026-10-01T09:00:00.000Z"), + locale: "de", + tz: null, + responsesEncrypted: new Uint8Array([6]), + }, + ]); mocks.userFindUnique.mockResolvedValue({ gender: "MALE", locale: "de" }); mockIncluded(["ALCOHOL_PATTERN"]); @@ -248,10 +260,59 @@ describe("profile section access in AI prompt paths", () => { "AUDIT-C (Screening, keine Diagnose): 5 von 12 am 2026-10-01, Screening-Grenzwert 5 erreicht", ); - mocks.auditFindFirst.mockClear(); + mocks.auditFindMany.mockClear(); mockIncluded(["GOALS"]); await getSelfContextTextForUser("user-1", "de"); - expect(mocks.auditFindFirst).not.toHaveBeenCalled(); + expect(mocks.auditFindMany).not.toHaveBeenCalled(); + }); + + it("drops the old alcohol pattern once an AUDIT-C entry exists", async () => { + mockIncluded(["ALCOHOL_PATTERN"]); + mocks.factFindMany.mockResolvedValue([ + { kind: "ALCOHOL_PATTERN", valueEncrypted: new Uint8Array([4]) }, + ]); + const alone = await getSelfContextTextForUser("user-1", "en"); + expect(alone).toContain("Alcohol pattern: Occasional"); + + mocks.auditFindMany.mockResolvedValue([ + { + id: "audit-1", + totalScore: 5, + takenAt: new Date("2026-10-01T09:00:00.000Z"), + locale: "en", + tz: null, + responsesEncrypted: new Uint8Array([6]), + }, + ]); + const text = await getSelfContextTextForUser("user-1", "en"); + expect(text).toContain("AUDIT-C"); + expect(text).not.toContain("Alcohol pattern"); + }); + + it("reads the newest entry whose answers open, as the read route does", async () => { + mocks.userFindUnique.mockResolvedValue({ gender: null, locale: "en" }); + mocks.auditFindMany.mockResolvedValue([ + { + id: "audit-sealed", + totalScore: 11, + takenAt: new Date("2026-10-05T09:00:00.000Z"), + locale: "en", + tz: null, + responsesEncrypted: new Uint8Array([7]), + }, + { + id: "audit-open", + totalScore: 5, + takenAt: new Date("2026-10-01T09:00:00.000Z"), + locale: "en", + tz: null, + responsesEncrypted: new Uint8Array([6]), + }, + ]); + mockIncluded(["ALCOHOL_PATTERN"]); + const text = await getSelfContextTextForUser("user-1", "en"); + expect(text).toContain("5 of 12"); + expect(text).not.toContain("11 of 12"); }); }); }); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index 4b5423721..6d251e4ad 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -47,6 +47,7 @@ import { } from "@/lib/profile/health-facts"; import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived"; import { auditCCutoff } from "@/lib/mental-health/audit-c"; +import { loadNewestReadableAuditC } from "@/lib/profile/audit-c-entries"; import { LIFE_FACT_KINDS, isLifeFactKind, @@ -636,20 +637,16 @@ export function composeLifeCircumstancesText( /** * The newest AUDIT-C entry for a prompt: only when the alcohol section is - * released, and without decrypting anything (the sum is stored in the clear, - * like the screeners'). The profile sex for the cut-off is read only when - * there is an entry to judge. + * released. "Newest" is the newest entry whose answers open, the same entry + * the read route shows as the result (`audit-c-entries.ts`). The profile sex + * for the cut-off is read only when there is an entry to judge. */ async function loadAuditCForPrompt( userId: string, included: ReadonlySet, ): Promise { if (!included.has("ALCOHOL_PATTERN")) return null; - const audit = await prisma.mentalHealthAssessment.findFirst({ - where: { userId, instrument: "AUDIT_C", deletedAt: null }, - orderBy: [{ takenAt: "desc" }, { id: "desc" }], - select: { totalScore: true, takenAt: true, locale: true, tz: true }, - }); + const audit = await loadNewestReadableAuditC(userId); if (!audit) return null; const user = await prisma.user.findUnique({ where: { id: userId }, @@ -839,6 +836,10 @@ export async function getSelfContextTextForUser( const factsText = composeStructuredHealthFactsText( factRows .filter((row) => !isLifeFactKind(row.kind)) + // The old one-word alcohol pattern gives way to an AUDIT-C entry: + // the questionnaire is the measured answer to the same question, and + // the two side by side would let a years-old guess argue with it. + .filter((row) => !(auditC && row.kind === "ALCOHOL_PATTERN")) .flatMap((row) => { const decrypted = decryptHealthProfileFactValue( row.kind as HealthProfileFactKind, diff --git a/src/lib/openapi/routes/life-circumstances.ts b/src/lib/openapi/routes/life-circumstances.ts index 706107f1b..d63cefa81 100644 --- a/src/lib/openapi/routes/life-circumstances.ts +++ b/src/lib/openapi/routes/life-circumstances.ts @@ -118,7 +118,11 @@ const lifeCircumstances = z screeningHints: z.array(screeningHint), }), auditC: z.object({ - latest: auditCResult.nullable(), + latest: auditCResult + .nullable() + .describe( + "The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only.", + ), history: z .array( z.object({ @@ -128,7 +132,11 @@ const lifeCircumstances = z }), ) .describe("Newest first, at most 20."), - offerDue: z.boolean(), + offerDue: z + .boolean() + .describe( + "No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older.", + ), instrument: z.object({ items: z .array( @@ -267,7 +275,7 @@ export const lifeCircumstancesPaths: NonNullable = { tags: ["Anamnesis"], summary: "Record an AUDIT-C (v1.43)", description: - "Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.", + "Scores the three items on the server. The entry is stored in the language of the wording the read route offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent, must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour.", parameters: [idempotencyKeyParameter], requestBody: { required: true, diff --git a/src/lib/profile/audit-c-entries.ts b/src/lib/profile/audit-c-entries.ts new file mode 100644 index 000000000..ce2d50862 --- /dev/null +++ b/src/lib/profile/audit-c-entries.ts @@ -0,0 +1,93 @@ +/** + * v1.43 (#614) — which AUDIT-C entry is "the newest" for every reader. + * + * The answers are sealed; the sum is stored in the clear. An entry whose + * answers this host can no longer open (a lost or retired key) still has a + * readable sum, so a reader that skipped decryption used to take it as the + * newest result while the read route, which needs the answers, showed an + * older one beside it and offered the questionnaire again on a date neither + * of them showed. One rule now holds everywhere a result is read (the read + * route's `latest` and `offerDue`, the Coach block, the doctor report and + * FHIR): the newest entry whose answers open. The history keeps listing every + * entry by its sum, so an unreadable one can still be found and removed. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +/** How many entries back a reader looks for one it can open. */ +export const AUDIT_C_HISTORY_LIMIT = 20; + +/** + * The sealed answers of one AUDIT-C entry. `schema` versions the blob the same + * way the screener responses are versioned. + */ +export interface AuditCResponses { + schema: 1; + items: [number, number, number]; +} + +/** Open the sealed AUDIT-C answers; null when this host cannot read them. */ +export function openAuditCResponses( + payload: Uint8Array, +): AuditCResponses | null { + try { + const parsed = JSON.parse( + decryptFromBytes(payload), + ) as Partial; + const items = parsed.items; + if ( + Array.isArray(items) && + items.length === 3 && + items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4) + ) { + return { schema: 1, items: items as [number, number, number] }; + } + getEvent()?.addWarning("audit-c responses shape drifted"); + } catch (error) { + getEvent()?.addWarning( + `audit-c responses decrypt failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return null; +} + +/** The first entry of `rows` (newest first) whose answers open. */ +export function newestReadableAuditC< + T extends { responsesEncrypted: Uint8Array }, +>(rows: readonly T[]): { row: T; items: [number, number, number] } | null { + for (const row of rows) { + const opened = openAuditCResponses(row.responsesEncrypted); + if (opened) return { row, items: opened.items }; + } + return null; +} + +/** The newest readable entry of a record, for the readers that need one. */ +export async function loadNewestReadableAuditC(userId: string): Promise<{ + id: string; + takenAt: Date; + totalScore: number; + locale: string; + tz: string | null; +} | null> { + const rows = await prisma.mentalHealthAssessment.findMany({ + where: { userId, instrument: "AUDIT_C", deletedAt: null }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take: AUDIT_C_HISTORY_LIMIT, + select: { + id: true, + takenAt: true, + totalScore: true, + locale: true, + tz: true, + responsesEncrypted: true, + }, + }); + const newest = newestReadableAuditC(rows); + if (!newest) return null; + const { responsesEncrypted: _sealed, ...entry } = newest.row; + return entry; +} diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts index 932fc5455..7eea2ff8a 100644 --- a/src/lib/profile/life-circumstances.ts +++ b/src/lib/profile/life-circumstances.ts @@ -15,6 +15,13 @@ import { Prisma } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { deriveAgeYears } from "@/lib/ai/coach/about-me"; +import { + AUDIT_C_HISTORY_LIMIT, + loadNewestReadableAuditC, + newestReadableAuditC, + openAuditCResponses, + type AuditCResponses, +} from "@/lib/profile/audit-c-entries"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { @@ -63,22 +70,11 @@ import { } from "@/lib/validations/life-circumstances"; const MS_PER_DAY = 86_400_000; -/** History entries the read route lists, newest first. */ -const AUDIT_C_HISTORY_LIMIT = 20; /** AUDIT-C is offered again once the newest entry is this old. */ const AUDIT_C_OFFER_AFTER_DAYS = 365; /** A step count this recent means activity is measured, not only reported. */ const MEASURED_STEPS_DAYS = 30; -/** - * The sealed answers of one AUDIT-C entry. `schema` versions the blob the same - * way the screener responses are versioned. - */ -interface AuditCResponses { - schema: 1; - items: [number, number, number]; -} - export interface AuditCResult { id: string; takenAt: string; @@ -160,31 +156,6 @@ function latest(...dates: Array): Date | null { return best; } -/** Open the sealed AUDIT-C answers; null when this host cannot read them. */ -function openAuditCResponses(payload: Uint8Array): AuditCResponses | null { - try { - const parsed = JSON.parse( - decryptFromBytes(payload), - ) as Partial; - const items = parsed.items; - if ( - Array.isArray(items) && - items.length === 3 && - items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4) - ) { - return { schema: 1, items: items as [number, number, number] }; - } - getEvent()?.addWarning("audit-c responses shape drifted"); - } catch (error) { - getEvent()?.addWarning( - `audit-c responses decrypt failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return null; -} - /** One stored entry scored for a profile sex; the source line follows `labelLocale`. */ export function toAuditCResult( row: { id: string; takenAt: Date; totalScore: number; locale: string }, @@ -327,17 +298,16 @@ export async function buildLifeCircumstances( const factOf = (kind: string): T | null => (facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null; - // AUDIT-C: the newest entry whose answers this host can open is the one - // shown in full; the history lists totals, which need no decryption. - let latestAuditC: AuditCResult | null = null; - for (const row of auditRows) { - const opened = openAuditCResponses(row.responsesEncrypted); - if (opened) { - latestAuditC = toAuditCResult(row, opened.items, sex, locale); - break; - } - } + // AUDIT-C: the newest entry whose answers this host can open is the + // result, and the offer is timed from it, as every other reader times it + // (`audit-c-entries.ts`). The history lists totals, which need no + // decryption, so an unreadable entry can still be removed. + const readable = newestReadableAuditC(auditRows); + const latestAuditC: AuditCResult | null = readable + ? toAuditCResult(readable.row, readable.items, sex, locale) + : null; const newestAuditC = auditRows[0]?.takenAt ?? null; + const newestReadableAt = readable?.row.takenAt ?? null; const sections = LIFE_SECTIONS.map((section) => { const sectionFacts = facts.filter((fact) => @@ -398,8 +368,8 @@ export async function buildLifeCircumstances( totalScore: row.totalScore, })), offerDue: - newestAuditC === null || - now.getTime() - newestAuditC.getTime() >= + newestReadableAt === null || + now.getTime() - newestReadableAt.getTime() >= AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY, instrument: auditCInstrument(locale), }, @@ -646,11 +616,7 @@ export async function loadLifeClinicalSummary( }, select: { kind: true, valueEncrypted: true }, }), - prisma.mentalHealthAssessment.findFirst({ - where: { userId, instrument: "AUDIT_C", deletedAt: null }, - orderBy: [{ takenAt: "desc" }, { id: "desc" }], - select: { id: true, takenAt: true, totalScore: true, locale: true }, - }), + loadNewestReadableAuditC(userId), ]); const value = (kind: string): T | null => { const row = rows.find((candidate) => candidate.kind === kind); diff --git a/tests/integration/life-circumstances.test.ts b/tests/integration/life-circumstances.test.ts index fa5088705..46fe9c8de 100644 --- a/tests/integration/life-circumstances.test.ts +++ b/tests/integration/life-circumstances.test.ts @@ -396,6 +396,79 @@ describe("AUDIT-C", () => { }); }); + it("scores against the wording offered and refuses another one or a future date", async () => { + // An English account is shown the English wording; a German cut-off for + // it would be the stricter S3 table applied to questions never asked. + const man = await makeUser({ locale: "en", gender: "MALE" }); + await signIn(man.id); + const wrong = await postAuditC({ + items: [2, 1, 1], + locale: "de", + source: "IOS", + }); + expect(wrong.status).toBe(422); + const { result } = await data<{ + result: { locale: string; cutoff: { source: string } | null }; + }>(await postAuditC({ items: [2, 1, 1], source: "IOS" }), 201); + expect(result.locale).toBe("en"); + expect(result.cutoff?.source).toBe("BRADLEY_2007"); + + // French has no validated wording, so the English one is offered and + // stored, whatever the account language says. + const french = await makeUser({ locale: "fr", gender: "MALE" }); + await signIn(french.id); + const fr = await data<{ result: { locale: string } }>( + await postAuditC({ items: [0, 0, 1], locale: "fr", source: "WEB" }), + 201, + ); + expect(fr.result.locale).toBe("en"); + + const future = await postAuditC({ + items: [0, 0, 0], + source: "WEB", + takenAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + expect(future.status).toBe(422); + const skew = await postAuditC({ + items: [0, 0, 0], + source: "WEB", + takenAt: new Date(Date.now() + 60 * 1000).toISOString(), + }); + expect(skew.status).toBe(201); + }); + + it("reads latest and the offer from the newest entry whose answers open", async () => { + const user = await makeUser(); + await signIn(user.id); + const older = await data<{ result: { id: string } }>( + await postAuditC({ + items: [1, 0, 0], + source: "WEB", + takenAt: "2025-01-10T09:00:00.000Z", + }), + 201, + ); + // A newer entry this host cannot open (its key is gone). + const sealed = await getPrismaClient().mentalHealthAssessment.create({ + data: { + userId: user.id, + instrument: "AUDIT_C", + locale: "en", + responsesEncrypted: new Uint8Array([9, 9, 9, 9]), + totalScore: 11, + severityBand: "unbanded", + takenAt: new Date(), + source: "WEB", + }, + }); + const life = await readLife(); + expect(life.auditC.latest?.id).toBe(older.result.id); + // Timed from the result shown, not from the entry nobody can read. + expect(life.auditC.offerDue).toBe(true); + // Still listed, so it can be removed. + expect(life.auditC.history.map((h) => h.id)).toContain(sealed.id); + }); + it("soft-deletes an entry once, and only an AUDIT-C entry of the record", async () => { const user = await makeUser(); await signIn(user.id); From 120b0966b1e22809a679768079560d2a60e240ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:38:36 +0200 Subject: [PATCH 05/13] fix(ai-usage): never regenerate more often on frugal than on standard The frugal sync gap is the larger of a day and the operator's sync gap, so an instance gap above 24 hours no longer leaves frugal ahead of standard. --- docs/api/openapi.yaml | 11 ++++++----- src/lib/ai/__tests__/usage-level.test.ts | 3 +++ src/lib/ai/usage-level.ts | 16 ++++++++++------ src/lib/insights/status-invalidation.ts | 3 ++- src/lib/openapi/routes/ai-usage.ts | 2 +- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6c1255829..4432b95d6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -81006,11 +81006,12 @@ components: - standard - full description: "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at - most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a - short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic - document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a - hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). - The interactive Coach is the same at every level." + most every 24 h after a sync (or the operator's sync gap when that is longer, so never more often than + `standard`) and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily + briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, + no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, + background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The + interactive Coach is the same at every level." CoachReasoningState: type: object properties: diff --git a/src/lib/ai/__tests__/usage-level.test.ts b/src/lib/ai/__tests__/usage-level.test.ts index 7b06d9c0c..bee432add 100644 --- a/src/lib/ai/__tests__/usage-level.test.ts +++ b/src/lib/ai/__tests__/usage-level.test.ts @@ -61,6 +61,9 @@ describe("resolveAiUsageLevel — every choice x default x ceiling", () => { describe("what each level does", () => { it("regenerates status cards after a sync: a day, the operator's gap, the hour", () => { expect(statusRegenGapHours("frugal", "sync", 6)).toBe(24); + // Never more often than standard: an operator gap above a day wins. + expect(statusRegenGapHours("frugal", "sync", 48)).toBe(48); + expect(statusRegenGapHours("standard", "sync", 48)).toBe(48); expect(statusRegenGapHours("standard", "sync", 6)).toBe(6); expect(statusRegenGapHours("standard", "sync", 12)).toBe(12); expect(statusRegenGapHours("full", "sync", 12)).toBe(1); diff --git a/src/lib/ai/usage-level.ts b/src/lib/ai/usage-level.ts index e81673b15..cba27e992 100644 --- a/src/lib/ai/usage-level.ts +++ b/src/lib/ai/usage-level.ts @@ -44,9 +44,12 @@ export interface AiUsageLevelEffect { * Minimum hours between two status-card regenerations of one scope after a * sync (a watch, an integration). `"instance"` reads the operator's sync * gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`, - * else six). + * else six). `{ instanceAtLeast }` reads the same gap but never goes + * below the floor, so a level meant to regenerate less often than + * `standard` cannot end up regenerating more often on an instance whose + * operator set a gap above the floor. */ - syncRegenGapHours: number | "instance"; + syncRegenGapHours: number | "instance" | { instanceAtLeast: number }; /** The same gap after a hand entry. */ manualRegenGapHours: number; /** @@ -89,7 +92,7 @@ export const AI_USAGE_LEVEL_EFFECTS: Readonly< Record> > = Object.freeze({ frugal: { - syncRegenGapHours: 24, + syncRegenGapHours: { instanceAtLeast: 24 }, manualRegenGapHours: 6, statusSnapshotChars: 10_000, backgroundReasoning: false, @@ -235,9 +238,10 @@ export function statusRegenGapHours( ): number { const effect = AI_USAGE_LEVEL_EFFECTS[level]; if (source === "manual") return effect.manualRegenGapHours; - return effect.syncRegenGapHours === "instance" - ? instanceSyncGapHours - : effect.syncRegenGapHours; + const gap = effect.syncRegenGapHours; + if (gap === "instance") return instanceSyncGapHours; + if (typeof gap === "number") return gap; + return Math.max(gap.instanceAtLeast, instanceSyncGapHours); } // ── Estimate ──────────────────────────────────────────────────────────────── diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index 033b289fa..d3f818ccf 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -63,7 +63,8 @@ export type StatusIngestSource = IngestSource; * day. Since v1.43 (#1217) the gap is chosen by source and by the record's AI * usage level (`statusRegenGapHours` in `@/lib/ai/usage-level`): a hand entry * keeps the hour (six on `frugal`), a sync waits the operator's sync gap on - * `standard` (default six hours), a day on `frugal`, the hour on `full`. + * `standard` (default six hours), a day on `frugal` (or the operator's gap + * when it is longer), the hour on `full`. */ async function ingestRegenGapMs( userId: string, diff --git a/src/lib/openapi/routes/ai-usage.ts b/src/lib/openapi/routes/ai-usage.ts index 1711b4267..e70982236 100644 --- a/src/lib/openapi/routes/ai-usage.ts +++ b/src/lib/openapi/routes/ai-usage.ts @@ -16,7 +16,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({ id: "AiUsageLevel", description: - "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.", + "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync (or the operator's sync gap when that is longer, so never more often than `standard`) and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.", }); export const aiUsageState = z From 8ae60e2cb3c42cef6ae3965ae8404c25ce2148d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:41:18 +0200 Subject: [PATCH 06/13] fix(coach): read free weekdays for a prompt only with the work section released --- src/lib/ai/coach/snapshot.ts | 4 +-- src/lib/profile/life-circumstances.ts | 37 ++++++++++++++++++++ tests/integration/life-circumstances.test.ts | 28 +++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 4c2a53dd8..2b2a4a01e 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -15,7 +15,7 @@ * values with weekday labels and aggregates the older window into * weekly buckets so the prompt budget stays tight. */ -import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances"; +import { loadFreeWeekdaySpansForPrompt } from "@/lib/profile/life-circumstances"; import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; import type { CoachDataCluster } from "@/lib/validations/coach-prefs"; @@ -807,7 +807,7 @@ async function buildCoachSnapshotImpl( sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null, userTz, ageYears: derivedProfile.ageYears, - freeWeekdaySpans: await loadFreeWeekdaySpans(userId), + freeWeekdaySpans: await loadFreeWeekdaySpansForPrompt(userId), snapshot, metrics, registerBlock, diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts index 7eea2ff8a..bab7ee582 100644 --- a/src/lib/profile/life-circumstances.ts +++ b/src/lib/profile/life-circumstances.ts @@ -47,6 +47,7 @@ import { computePackYears, DEFAULT_FREE_WEEKDAYS, isReviewDue, + LIFE_KIND_AI_SECTION, screeningHints, type FreeWeekdaysSpan, type PackYears, @@ -214,6 +215,42 @@ export async function loadFreeWeekdaySpans( } } +/** + * The free-weekday answers for a prompt: only when the section they belong + * to (`WORK`) is released to the AI. Otherwise the answers are never read or + * decrypted for a prompt, and the night-by-night day type falls back to the + * weekend default, exactly as for a record that never answered. The data + * surfaces (the sleep page, the dashboard) read `loadFreeWeekdaySpans` + * directly: they show the person their own answer, no model sees it. + */ +export async function loadFreeWeekdaySpansForPrompt( + userId: string, +): Promise { + // Fail-soft and fail-closed, like the read below: a failed switch read + // gives the weekend default and never the answers. + try { + const profile = await prisma.userHealthProfile.findUnique({ + where: { userId }, + select: { aiIncludedSections: true }, + }); + if ( + !storedSections(profile?.aiIncludedSections).has( + LIFE_KIND_AI_SECTION.FREE_WEEKDAYS, + ) + ) { + return []; + } + } catch (error) { + getEvent()?.addWarning( + `free weekdays switch read failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return []; + } + return loadFreeWeekdaySpans(userId); +} + /** Assemble the whole read model for one record. */ export async function buildLifeCircumstances( userId: string, diff --git a/tests/integration/life-circumstances.test.ts b/tests/integration/life-circumstances.test.ts index 46fe9c8de..9c1cd8d12 100644 --- a/tests/integration/life-circumstances.test.ts +++ b/tests/integration/life-circumstances.test.ts @@ -297,6 +297,34 @@ describe("life-circumstance facts", () => { }); }); +describe("free weekdays in a prompt", () => { + it("are read for a prompt only while the work section is released", async () => { + const user = await makeUser(); + await signIn(user.id); + await data( + await postFact({ kind: "FREE_WEEKDAYS", value: VALUES.FREE_WEEKDAYS }), + 201, + ); + const { loadFreeWeekdaySpans, loadFreeWeekdaySpansForPrompt } = + await import("@/lib/profile/life-circumstances"); + + await getPrismaClient().userHealthProfile.upsert({ + where: { userId: user.id }, + create: { userId: user.id, aiIncludedSections: ["WORK"] }, + update: { aiIncludedSections: ["WORK"] }, + }); + expect(await loadFreeWeekdaySpansForPrompt(user.id)).toHaveLength(1); + + await getPrismaClient().userHealthProfile.update({ + where: { userId: user.id }, + data: { aiIncludedSections: ["ABOUT_ME"] }, + }); + expect(await loadFreeWeekdaySpansForPrompt(user.id)).toEqual([]); + // The data surfaces still show the person their own answer. + expect(await loadFreeWeekdaySpans(user.id)).toHaveLength(1); + }); +}); + describe("AUDIT-C", () => { async function postAuditC(body: unknown): Promise { const { POST } = await import("@/app/api/anamnesis/audit-c/route"); From 23f008c8bb8ba0f533766319a73b0b722fde85ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:45:06 +0200 Subject: [PATCH 07/13] fix(anamnesis): serialise the AI switch writes so a PUT cannot resurrect a section --- .../__tests__/route-concurrency.test.ts | 1 + .../__tests__/route-module-gate.test.ts | 2 + src/app/api/coach/about-me/route.ts | 4 ++ src/lib/profile/life-circumstances.ts | 20 ++++++++ tests/integration/life-circumstances.test.ts | 47 +++++++++++++++++++ 5 files changed, 74 insertions(+) diff --git a/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts b/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts index 77954ad35..022796ebb 100644 --- a/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts @@ -50,6 +50,7 @@ const { profile, userUpdate, transaction } = vi.hoisted(() => { }; const userUpdate = vi.fn(async () => ({})); const tx = { + $queryRaw: vi.fn(async () => []), userHealthProfile: profile, user: { update: userUpdate }, }; diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts index 402286c7d..95dcfe264 100644 --- a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -44,6 +44,7 @@ const { const profileUpsert = vi.fn(async () => ({ updatedAt: new Date(0) })); const userUpdate = vi.fn(async () => ({})); const tx = { + $queryRaw: vi.fn(async () => []), userHealthProfile: { findUnique: profileFindUnique, upsert: profileUpsert, @@ -246,6 +247,7 @@ describe("about-me module gate", () => { transaction.mockImplementationOnce( async (callback: (client: unknown) => unknown) => { const result = await callback({ + $queryRaw: vi.fn(async () => []), userHealthProfile: { findUnique: profileFindUnique, upsert: txProfileUpsert, diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index 5cc84ed6c..bacf26cad 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -42,6 +42,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { prisma } from "@/lib/db"; +import { lockAiSections } from "@/lib/profile/life-circumstances"; import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; @@ -246,6 +247,9 @@ export const PUT = apiHandler(async (req: Request) => { // A PUT here replaces the eight sections it lists and leaves the // life-circumstance switches (v1.43) exactly as stored. if (parsed.data.aiIncludedSections !== undefined) { + // Taken before the read, so a switch changed concurrently is read + // after it commits and carried over as it now stands. + await lockAiSections(tx, user.id); const stored = await tx.userHealthProfile.findUnique({ where: { userId: user.id }, select: { aiIncludedSections: true }, diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts index bab7ee582..3ee481dd5 100644 --- a/src/lib/profile/life-circumstances.ts +++ b/src/lib/profile/life-circumstances.ts @@ -445,6 +445,25 @@ function lifeSwitches( ) as Record; } +/** + * Serialise every read-modify-write of a profile's AI switches. Two routes + * write the one array: this module's switch route and the about-me PUT, which + * replaces its eight sections and carries the life switches over as stored. + * Without a lock, a PUT that read the array before a concurrent switch-off + * committed wrote the old value back and turned the section on again. A + * transaction-scoped advisory lock covers the first write too, when there is + * no row yet to lock. + */ +export async function lockAiSections( + tx: Prisma.TransactionClient, + userId: string, +): Promise { + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended(${`ai-sections:${userId}`}, 0)) + `; +} + /** * Switch life-circumstance sections in or out of the AI inclusion list. Only * the named sections change; the other stored values, the five about-me @@ -459,6 +478,7 @@ export async function updateLifeAiSections( base?: Date, ): Promise { return prisma.$transaction(async (tx) => { + await lockAiSections(tx, userId); const row = await tx.userHealthProfile.findUnique({ where: { userId }, select: { aiIncludedSections: true, updatedAt: true }, diff --git a/tests/integration/life-circumstances.test.ts b/tests/integration/life-circumstances.test.ts index 9c1cd8d12..508c8a53c 100644 --- a/tests/integration/life-circumstances.test.ts +++ b/tests/integration/life-circumstances.test.ts @@ -578,6 +578,53 @@ describe("AI switches and confirmations", () => { ]); }); + it("never turns a section on again that a concurrent switch turned off", async () => { + const user = await makeUser({ + modulePreferencesJson: { mentalHealth: true, coach: true }, + }); + await signIn(user.id); + const prisma = getPrismaClient(); + await prisma.userHealthProfile.create({ + data: { userId: user.id, aiIncludedSections: ["ABOUT_ME", "HABITS"] }, + }); + const { lockAiSections } = await import("@/lib/profile/life-circumstances"); + const aboutMe = await import("@/app/api/coach/about-me/route"); + + // The switch-off holds its transaction open while the PUT starts. + let release!: () => void; + const held = new Promise((resolve) => (release = resolve)); + let locked!: () => void; + const isLocked = new Promise((resolve) => (locked = resolve)); + const switchOff = prisma.$transaction(async (tx) => { + await lockAiSections(tx, user.id); + await tx.userHealthProfile.update({ + where: { userId: user.id }, + data: { aiIncludedSections: ["ABOUT_ME"] }, + }); + locked(); + await held; + }); + await isLocked; + const put = (aboutMe.PUT as unknown as Handler)( + request("PUT", "/api/coach/about-me", { + aiIncludedSections: ["ABOUT_ME", "CONDITIONS"], + }), + ); + // Long enough for an unlocked PUT to have read the old array. + await new Promise((resolve) => setTimeout(resolve, 300)); + release(); + await switchOff; + await data(await put); + + const stored = await prisma.userHealthProfile.findUniqueOrThrow({ + where: { userId: user.id }, + }); + expect([...stored.aiIncludedSections].sort()).toEqual([ + "ABOUT_ME", + "CONDITIONS", + ]); + }); + it("answers a stale token with about_me_conflict", async () => { const user = await makeUser(); await signIn(user.id); From 3c44f3d16705ea7166eeb6c14598bbdb5440eeca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:45:59 +0200 Subject: [PATCH 08/13] fix(auth): ask the provider for a fresh sign-in when claiming a profile --- .../auth/oidc/login/__tests__/route.test.ts | 46 ++++++++++++++++++- src/app/api/auth/oidc/login/route.ts | 2 + src/lib/auth/__tests__/oidc.test.ts | 17 +++++++ src/lib/auth/oidc.ts | 8 ++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts index 1d9fc1584..cf283fb6a 100644 --- a/src/app/api/auth/oidc/login/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -40,9 +40,25 @@ vi.mock("@/lib/auth/oidc", async () => { }; }); +vi.mock("@/lib/managed-profiles/claim-cookie", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/managed-profiles/claim-cookie") + >()), + readClaimCookie: vi.fn(), +})); +vi.mock("@/lib/managed-profiles/handover", () => ({ + isHandoverUsable: vi.fn(), +})); + import { GET } from "../route"; +import { readClaimCookie } from "@/lib/managed-profiles/claim-cookie"; +import { isHandoverUsable } from "@/lib/managed-profiles/handover"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; -import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc"; +import { + buildAuthorizationUrl, + discoverOidcMetadata, + getOidcConfig, +} from "@/lib/auth/oidc"; function makeRequest(path = "/api/auth/oidc/login"): NextRequest { return new NextRequest(`http://localhost${path}`); @@ -248,6 +264,34 @@ describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => { expect(res.cookies.get("oidc_auth_state")).toBeUndefined(); }); + it("asks the provider for a fresh sign-in on a claim, and only there", async () => { + vi.mocked(discoverOidcMetadata).mockResolvedValue({ + issuer: "https://idp.example.com", + authorization_endpoint: "https://idp.example.com/authorize", + token_endpoint: "https://idp.example.com/token", + jwks_uri: "https://idp.example.com/jwks", + }); + vi.mocked(buildAuthorizationUrl).mockReturnValue( + "https://idp.example.com/authorize?x=1", + ); + vi.mocked(readClaimCookie).mockReturnValue({ + handoverId: "handover-1", + binding: "binding-1", + } as never); + vi.mocked(isHandoverUsable).mockResolvedValue(true); + + await GET(makeRequest("/api/auth/oidc/login?intent=claim")); + expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({ + forceLogin: true, + }); + + vi.mocked(buildAuthorizationUrl).mockClear(); + await GET(makeRequest("/api/auth/oidc/login")); + expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({ + forceLogin: false, + }); + }); + it("routes the claim's other failures to the claim page too", async () => { vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ allowed: false, diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index dd0dc1cc8..f783e171c 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -156,6 +156,8 @@ export const GET = apiHandler(async (req: NextRequest) => { nonce, codeChallenge, redirectUri, + // A claim only; an ordinary sign-in keeps single sign-on. + forceLogin: claimBinding !== null, }); const response = NextResponse.redirect(authorizationUrl); diff --git a/src/lib/auth/__tests__/oidc.test.ts b/src/lib/auth/__tests__/oidc.test.ts index dbcadf1a1..d2f821cdc 100644 --- a/src/lib/auth/__tests__/oidc.test.ts +++ b/src/lib/auth/__tests__/oidc.test.ts @@ -253,6 +253,23 @@ describe("buildAuthorizationUrl", () => { expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); expect(url.searchParams.get("scope")).toBe(config.scopes); + expect(url.searchParams.has("prompt")).toBe(false); + }); + + it("asks for a fresh sign-in only when told to", () => { + configureEnv(); + const url = new URL( + buildAuthorizationUrl({ + metadata: METADATA_DOC, + config: getOidcConfig()!, + state: "state-1", + nonce: "nonce-1", + codeChallenge: "challenge-1", + redirectUri: "https://healthlog.example.com/api/auth/oidc/callback", + forceLogin: true, + }), + ); + expect(url.searchParams.get("prompt")).toBe("login"); }); }); diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index 3282bd95e..1c5b86f56 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -237,6 +237,13 @@ export function buildAuthorizationUrl(params: { nonce: string; codeChallenge: string; redirectUri: string; + /** + * Ask the provider to authenticate afresh (`prompt=login`, OpenID Connect + * Core 3.1.2.1) instead of reusing the browser's provider session. A claim + * hands a profile to whoever signs in, so it must be the person at the + * keyboard now, not a session someone else left open in this browser. + */ + forceLogin?: boolean; }): string { const url = new URL(params.metadata.authorization_endpoint); url.search = new URLSearchParams({ @@ -248,6 +255,7 @@ export function buildAuthorizationUrl(params: { nonce: params.nonce, code_challenge: params.codeChallenge, code_challenge_method: "S256", + ...(params.forceLogin ? { prompt: "login" } : {}), }).toString(); return url.toString(); } From 37645fc4bbdc79fa60361b0d9c0e5208ddd3063a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:48:13 +0200 Subject: [PATCH 09/13] fix(measurements): cut drain days in the zone the server publishes for the account --- .../consolidation-scan-paged.test.ts | 30 +++++++++++++++++++ src/lib/measurements/consolidation-base.ts | 8 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts b/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts index 1add7ea76..28661fa62 100644 --- a/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts +++ b/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; +const appSettings = vi.hoisted(() => ({ + findUnique: vi.fn(async () => ({ defaultUserTimezone: "America/New_York" })), +})); +vi.mock("@/lib/db", () => ({ prisma: { appSettings } })); + import { bucketRowsByDay, iterateDayBuckets, @@ -303,4 +308,29 @@ describe("runConsolidation — streamed walk", () => { expect(result.stoppedEarly).toBe(true); expect(findMany).toHaveBeenCalledTimes(1); }); + + it("cuts days in the zone the server publishes for the account", async () => { + // A stored zone the server sets aside reads as the instance default, as + // it does for `/api/auth/me` and the hour-bucket writer; never as the + // drain's own fallback. + const findMany = buildPagingFindMany(hourlyRows(1)); + const base = params(findMany, async () => ({ + kind: "written" as const, + sourceRowsRemoved: 0, + })); + const zones: string[] = []; + await runConsolidation({ + ...base, + prismaClient: { + user: { + findMany: vi + .fn() + .mockResolvedValue([{ id: "user-2", timezone: null }]), + }, + measurement: { findMany }, + } as unknown as PrismaClient, + onUserStart: ({ tz }) => zones.push(tz), + }); + expect(zones).toEqual(["America/New_York"]); + }); }); diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index 0f1b21a73..2b717a0d3 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -44,6 +44,7 @@ import { type PerSampleRow, } from "./consolidation-tz"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; // Re-export the shared grace constant so the established // `consolidation-base` import surface stays unchanged for callers. @@ -454,7 +455,12 @@ export async function runConsolidation( const users = await loadConsolidationUsers(prismaClient, options.userId); walk: for (const user of users) { - const tz = resolveUserTimezone(user.timezone); + // The zone the server publishes for the account (`/api/auth/me`), which + // is the zone the phone cuts its `stats:` days and hour buckets in and + // the one the hour-bucket writer stores them under. A stored value the + // server sets aside falls back to the instance default here as it does + // there, never to a second fallback of the drain's own. + const tz = await resolveStoredTimezone(user.timezone); // Aligned to the account's local day: a day is folded whole or not at // all, never in two runs (see `foldBoundary`). const cutoffAt = From cc9d523bc86993c724c5684b37a2c14b30cca5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:49:58 +0200 Subject: [PATCH 10/13] fix(nutrients): recompute supplement rows when the module returns or the zone moves --- src/app/api/auth/me/modules/route.ts | 7 ++++ .../auth/me/timezone/__tests__/route.test.ts | 8 ++++ src/app/api/auth/me/timezone/route.ts | 3 ++ src/app/api/record-settings/[family]/route.ts | 3 ++ .../__tests__/recompute-on-switch.test.ts | 41 +++++++++++++++++++ .../medications/ingredients/recompute-job.ts | 24 +++++++++++ src/lib/record-settings/modules.ts | 10 +++++ 7 files changed, 96 insertions(+) create mode 100644 src/lib/medications/ingredients/__tests__/recompute-on-switch.test.ts diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 5c10e8d93..abeceb8de 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -44,6 +44,7 @@ import { } from "@/lib/optimistic-lock"; import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate"; import { modulePrefsPatchSchema } from "@/lib/validations/modules"; +import { recomputeSupplementsIfNutrientsTurnedOn } from "@/lib/medications/ingredients/recompute-job"; export const dynamic = "force-dynamic"; @@ -140,6 +141,12 @@ export const PATCH = apiHandler(async (req: Request) => { }); if ("conflict" in guarded) return guarded.conflict; + await recomputeSupplementsIfNutrientsTurnedOn( + user.id, + existing?.modulePreferencesJson, + merged, + ); + // A module toggle can add or remove a Health Score pillar, so the // cached composite was computed from a composition that no longer // holds. Without this the score stays stale for up to an hour after a diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index 8fa2a9216..5cd3cfff8 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -31,6 +31,12 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ enqueueUserMedicationComplianceBackfill: refoldMock, })); +// The supplement nutrient rows are keyed by the same local day. +const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true }))); +vi.mock("@/lib/medications/ingredients/recompute-job", () => ({ + enqueueSupplementNutrientRecompute: nutrientMock, +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -118,6 +124,7 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "America/New_York" })); expect(res.status).toBe(200); expect(refoldMock).toHaveBeenCalledWith("user-1"); + expect(nutrientMock).toHaveBeenCalledWith("user-1"); }); it("does not re-fold when the zone is unchanged", async () => { @@ -130,6 +137,7 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); expect(res.status).toBe(200); expect(refoldMock).not.toHaveBeenCalled(); + expect(nutrientMock).not.toHaveBeenCalled(); }); it("trims whitespace before validating", async () => { diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 0bc0c0503..ad4ff0485 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -29,6 +29,7 @@ import { import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; +import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; export const PUT = apiHandler(async (request: NextRequest) => { @@ -69,6 +70,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { // job; best-effort, the singleton key collapses rapid re-picks. if ((before?.timezone ?? null) !== tz) { const refold = await enqueueUserMedicationComplianceBackfill(user.id); + // The supplement nutrient rows are keyed by the same local day. + await enqueueSupplementNutrientRecompute(user.id); annotate({ meta: { timezone_compliance_refold_enqueued: refold.enqueued, diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index c899bd3ba..86b6b465c 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -1,5 +1,6 @@ import type { NextRequest } from "next/server"; +import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; import { apiHandler, requireGuardianAuth, @@ -239,6 +240,8 @@ export const PATCH = apiHandler( } if (patch.timezone !== undefined) { invalidateUserMedications(access.recordId, { evict: true }); + // The supplement nutrient rows are keyed by the local day. + await enqueueSupplementNutrientRecompute(access.recordId); } settings = { ...record, diff --git a/src/lib/medications/ingredients/__tests__/recompute-on-switch.test.ts b/src/lib/medications/ingredients/__tests__/recompute-on-switch.test.ts new file mode 100644 index 000000000..85a96b078 --- /dev/null +++ b/src/lib/medications/ingredients/__tests__/recompute-on-switch.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const send = vi.hoisted(() => vi.fn(async () => "job-1")); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ send }), +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("../recompute", () => ({ + recomputeSupplementNutrientsForUser: vi.fn(), +})); + +import { recomputeSupplementsIfNutrientsTurnedOn } from "../recompute-job"; + +beforeEach(() => send.mockClear()); + +describe("the supplement recompute after a module switch", () => { + it("queues the full recompute when nutrients comes back on", async () => { + await recomputeSupplementsIfNutrientsTurnedOn( + "user-1", + { nutrients: false }, + { nutrients: true }, + ); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1]).toMatchObject({ userId: "user-1" }); + }); + + it("queues nothing when the switch did not move to on", async () => { + await recomputeSupplementsIfNutrientsTurnedOn( + "user-1", + { nutrients: true }, + { nutrients: true, mood: false }, + ); + await recomputeSupplementsIfNutrientsTurnedOn( + "user-1", + { nutrients: true }, + { nutrients: false }, + ); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/ingredients/recompute-job.ts b/src/lib/medications/ingredients/recompute-job.ts index 2f0e00759..e42624420 100644 --- a/src/lib/medications/ingredients/recompute-job.ts +++ b/src/lib/medications/ingredients/recompute-job.ts @@ -17,6 +17,8 @@ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; +import { normalisePrefs } from "@/lib/modules/gate"; +import { isOptInModule } from "@/lib/modules/registry"; import { recomputeSupplementNutrientsForUser } from "./recompute"; @@ -67,6 +69,28 @@ export async function enqueueSupplementNutrientRecompute( return { queued: false }; } +/** The account's own `nutrients` switch in a stored preference blob. */ +function nutrientsSwitchOn(prefs: unknown): boolean { + const value = normalisePrefs(prefs).nutrients; + return isOptInModule("nutrients") ? value === true : value !== false; +} + +/** + * Queue the full recompute when a preference write turned the account's + * `nutrients` switch back on. While the module is off nothing is written + * (`recompute.ts`), so a dose removed, a composition changed or a zone moved + * in the meantime left the stored rows as they were; the first pass after + * the switch puts them right instead of the next unrelated change. + */ +export async function recomputeSupplementsIfNutrientsTurnedOn( + userId: string, + before: unknown, + after: unknown, +): Promise { + if (nutrientsSwitchOn(before) || !nutrientsSwitchOn(after)) return; + await enqueueSupplementNutrientRecompute(userId); +} + /** The pg-boss handler body for one job. */ export async function runSupplementNutrientRecompute( payload: SupplementNutrientRecomputePayload, diff --git a/src/lib/record-settings/modules.ts b/src/lib/record-settings/modules.ts index 13428f2ad..0c2df12e9 100644 --- a/src/lib/record-settings/modules.ts +++ b/src/lib/record-settings/modules.ts @@ -35,6 +35,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { prisma, toJson } from "@/lib/db"; +import { recomputeSupplementsIfNutrientsTurnedOn } from "@/lib/medications/ingredients/recompute-job"; export interface RecordModuleWrite { /** The record whose modules these are — never the actor, under a switch. */ @@ -59,10 +60,19 @@ export async function writeRecordModulePreferences( input: RecordModuleWrite, ): Promise<{ cycleTrackingEnabled: boolean | null }> { if (input.modulePreferences !== undefined) { + const before = await prisma.user.findUnique({ + where: { id: input.recordId }, + select: { modulePreferencesJson: true }, + }); await prisma.user.update({ where: { id: input.recordId }, data: { modulePreferencesJson: toJson(input.modulePreferences) }, }); + await recomputeSupplementsIfNutrientsTurnedOn( + input.recordId, + before?.modulePreferencesJson, + input.modulePreferences, + ); } if (input.cycleTrackingEnabled !== undefined) { await getOrCreateCycleProfile(input.recordId); From f360d221e145551111f036f7117ece3e2382cab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:55:31 +0200 Subject: [PATCH 11/13] fix(admin): record the pulse rebuild and let a stopped one run again The rebuild after apply now writes its state beside the dry run (queued, running, done with counts, failed); a run left queued or running without a job reads as failed. The card shows it and offers a new rebuild after a failure, which the route accepts once the method is applied, without a new dry run. --- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/ko.json | 10 +- messages/pl.json | 10 +- .../pulse-day-mean/__tests__/route.test.ts | 76 +++++++++++- .../admin/maintenance/pulse-day-mean/route.ts | 62 ++++++++-- .../__tests__/pulse-day-mean-card.test.tsx | 36 +++++- src/components/admin/pulse-day-mean-card.tsx | 51 +++++++- .../pulse-day-mean-rebuild-state.test.ts | 73 ++++++++++++ src/lib/jobs/pulse-day-mean.ts | 112 ++++++++++++++---- .../__tests__/recompute-on-switch.test.ts | 2 +- src/lib/validations/pulse-day-mean.ts | 37 +++++- .../integration/pulse-day-mean-method.test.ts | 13 +- 16 files changed, 483 insertions(+), 49 deletions(-) create mode 100644 src/lib/jobs/__tests__/pulse-day-mean-rebuild-state.test.ts diff --git a/messages/de.json b/messages/de.json index d514020f9..8b7dae3ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8223,7 +8223,15 @@ "busy": "Ein Probelauf oder eine Neuberechnung läuft noch. Versuche es erneut, sobald er fertig ist.", "dryRunRequired": "Starte zuerst einen Probelauf. Zum Anwenden braucht es einen, der in den letzten sieben Tagen abgeschlossen wurde.", "generic": "Die Anfrage ist nicht durchgegangen." - } + }, + "rebuild": { + "queued": "Die Neuberechnung wartet auf den Hintergrunddienst.", + "running": "Die Pulstage werden neu berechnet.", + "done": "Pulstage am {at} für {accounts} Konten neu berechnet.", + "failed": "Die Neuberechnung ist abgebrochen. Das App-Log nennt die Ursache; starte sie erneut." + }, + "rebuildAgain": "Erneut berechnen", + "rebuildStarted": "Neuberechnung gestartet." } }, "carrier": "Netzbetreiber", diff --git a/messages/en.json b/messages/en.json index 84b69140a..2a30cc523 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8223,7 +8223,15 @@ "busy": "A dry run or recalculation is still running. Try again once it has finished.", "dryRunRequired": "Run a dry run first. Applying needs one that finished in the last seven days.", "generic": "The request did not go through." - } + }, + "rebuild": { + "queued": "The recalculation is waiting for the background worker.", + "running": "The pulse days are being recalculated.", + "done": "Pulse days recalculated on {at} for {accounts} accounts.", + "failed": "The recalculation stopped. The app log names the cause; run it again." + }, + "rebuildAgain": "Recalculate again", + "rebuildStarted": "Recalculation started." } }, "carrier": "Carrier", diff --git a/messages/es.json b/messages/es.json index 7f428795b..49d60e6f5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8223,7 +8223,15 @@ "busy": "Todavía hay una prueba o un recálculo en marcha. Inténtalo de nuevo cuando termine.", "dryRunRequired": "Ejecuta primero una prueba. Para aplicar hace falta una terminada en los últimos siete días.", "generic": "La solicitud no se completó." - } + }, + "rebuild": { + "queued": "El recálculo espera al proceso en segundo plano.", + "running": "Se están recalculando los días de pulso.", + "done": "Días de pulso recalculados el {at} para {accounts} cuentas.", + "failed": "El recálculo se detuvo. El registro de la app indica la causa; vuelve a ejecutarlo." + }, + "rebuildAgain": "Recalcular de nuevo", + "rebuildStarted": "Recálculo iniciado." } }, "carrier": "Operador", diff --git a/messages/fr.json b/messages/fr.json index 8f7f8f69f..d2453af72 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8223,7 +8223,15 @@ "busy": "Une simulation ou un recalcul est encore en cours. Réessayez une fois terminé.", "dryRunRequired": "Lancez d'abord une simulation. L'application en exige une terminée au cours des sept derniers jours.", "generic": "La requête n'a pas abouti." - } + }, + "rebuild": { + "queued": "Le recalcul attend le processus en arrière-plan.", + "running": "Les jours de pouls sont en cours de recalcul.", + "done": "Jours de pouls recalculés le {at} pour {accounts} comptes.", + "failed": "Le recalcul s’est arrêté. Le journal de l’app en indique la cause ; relancez-le." + }, + "rebuildAgain": "Recalculer à nouveau", + "rebuildStarted": "Recalcul lancé." } }, "carrier": "Opérateur", diff --git a/messages/it.json b/messages/it.json index 4e3f7b5b5..fefbb8d30 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8223,7 +8223,15 @@ "busy": "Una prova o un ricalcolo è ancora in corso. Riprova quando è terminato.", "dryRunRequired": "Avvia prima una prova. Per applicare ne serve una terminata negli ultimi sette giorni.", "generic": "La richiesta non è andata a buon fine." - } + }, + "rebuild": { + "queued": "Il ricalcolo attende il processo in background.", + "running": "I giorni del polso sono in fase di ricalcolo.", + "done": "Giorni del polso ricalcolati il {at} per {accounts} account.", + "failed": "Il ricalcolo si è interrotto. Il log dell’app indica la causa; avvialo di nuovo." + }, + "rebuildAgain": "Ricalcola di nuovo", + "rebuildStarted": "Ricalcolo avviato." } }, "carrier": "Operatore", diff --git a/messages/ko.json b/messages/ko.json index 95d537ce8..ec0a66d5b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8198,7 +8198,15 @@ "busy": "시험 실행이나 재계산이 아직 진행 중입니다. 끝난 뒤 다시 시도하세요.", "dryRunRequired": "먼저 시험 실행을 하세요. 적용하려면 최근 7일 안에 끝난 시험 실행이 필요합니다.", "generic": "요청이 처리되지 않았습니다." - } + }, + "rebuild": { + "queued": "재계산이 백그라운드 작업을 기다리고 있습니다.", + "running": "맥박 일별 값을 재계산하고 있습니다.", + "done": "{at}에 계정 {accounts}개의 맥박 일별 값을 재계산했습니다.", + "failed": "재계산이 중단되었습니다. 앱 로그에 원인이 있습니다. 다시 실행하세요." + }, + "rebuildAgain": "다시 계산", + "rebuildStarted": "재계산을 시작했습니다." } }, "carrier": "통신사", diff --git a/messages/pl.json b/messages/pl.json index 5fe13ba60..7aecb1066 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8223,7 +8223,15 @@ "busy": "Próba lub przeliczanie wciąż trwa. Spróbuj ponownie, gdy się zakończy.", "dryRunRequired": "Najpierw uruchom próbę. Do zastosowania potrzebna jest próba zakończona w ciągu ostatnich siedmiu dni.", "generic": "Żądanie nie zostało zrealizowane." - } + }, + "rebuild": { + "queued": "Ponowne przeliczenie czeka na proces w tle.", + "running": "Dni pulsu są przeliczane.", + "done": "Dni pulsu przeliczone {at} dla kont: {accounts}.", + "failed": "Przeliczanie zostało przerwane. Przyczynę podaje log aplikacji; uruchom je ponownie." + }, + "rebuildAgain": "Przelicz ponownie", + "rebuildStarted": "Przeliczanie rozpoczęte." } }, "carrier": "Operator", diff --git a/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts b/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts index 3f05cb0a1..26c89266e 100644 --- a/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts +++ b/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts @@ -66,10 +66,14 @@ const post = (body: unknown) => const NOW = new Date("2026-10-16T12:00:00Z"); const send = vi.fn(); -function settings(dryRun: unknown, method = "hour_mean_v1") { +function settings( + dryRun: unknown, + method = "hour_mean_v1", + appliedAt: Date | null = null, +) { vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ pulseDayMeanMethod: method, - pulseDayMeanAppliedAt: null, + pulseDayMeanAppliedAt: appliedAt, pulseDayMeanDryRunJson: dryRun, } as never); } @@ -136,6 +140,7 @@ describe("GET", () => { method: "hour_mean_v1", appliedAt: null, lastDryRun: doneAt("2026-10-15T08:00:00.000Z"), + lastRebuild: null, }); }); @@ -199,7 +204,9 @@ describe("POST dry_run", () => { const res = await post({ action: "dry_run" }); expect(res.status).toBe(409); expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual( - expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }), + expect.objectContaining({ + update: { pulseDayMeanDryRunJson: { ...previous, rebuild: null } }, + }), ); }); }); @@ -264,10 +271,71 @@ describe("POST apply", () => { }); }); +describe("POST rebuild", () => { + const failedRebuild = { + status: "failed", + startedAt: "2026-10-15T09:00:00.000Z", + finishedAt: "2026-10-15T09:05:00.000Z", + accounts: null, + rows: null, + }; + + it("is refused before any apply", async () => { + settings(doneAt("2026-10-15T08:00:00.000Z")); + const res = await post({ action: "rebuild" }); + expect(res.status).toBe(409); + expect((await res.json()).meta.errorCode).toBe( + "maintenance.pulseDayMean.dryRunRequired", + ); + expect(send).not.toHaveBeenCalled(); + }); + + it("runs the recalculation again after a stopped one, without a new dry run", async () => { + // The dry run is three weeks old: apply would refuse it, a rebuild does not. + settings( + { ...doneAt("2026-09-20T08:00:00.000Z"), rebuild: failedRebuild }, + "weighted_hour_mean_v2", + new Date("2026-09-20T09:00:00.000Z"), + ); + const res = await post({ action: "rebuild" }); + expect(res.status).toBe(202); + expect(send).toHaveBeenCalledWith( + "pulse-day-mean", + { action: "rebuild" }, + expect.anything(), + ); + expect( + vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0], + ).toMatchObject({ + update: { + pulseDayMeanDryRunJson: { rebuild: { status: "queued" } }, + }, + }); + expect(auditLog).toHaveBeenCalledWith( + "admin.pulseDayMean.rebuild", + expect.anything(), + ); + }); + + it("shows a rebuild left queued with no job behind it as failed", async () => { + settings( + { + ...doneAt("2026-10-15T08:00:00.000Z"), + rebuild: { ...failedRebuild, status: "running", finishedAt: null }, + }, + "weighted_hour_mean_v2", + new Date("2026-10-15T09:00:00.000Z"), + ); + const body = await (await get()).json(); + expect(body.data.lastRebuild.status).toBe("failed"); + expect(body.data.lastDryRun.status).toBe("done"); + }); +}); + describe("POST body", () => { it("is strict", async () => { expect((await post({ action: "apply", force: true })).status).toBe(422); - expect((await post({ action: "rebuild" })).status).toBe(422); + expect((await post({ action: "purge" })).status).toBe(422); expect(send).not.toHaveBeenCalled(); }); diff --git a/src/app/api/admin/maintenance/pulse-day-mean/route.ts b/src/app/api/admin/maintenance/pulse-day-mean/route.ts index c94e34f50..12e0ebf5d 100644 --- a/src/app/api/admin/maintenance/pulse-day-mean/route.ts +++ b/src/app/api/admin/maintenance/pulse-day-mean/route.ts @@ -4,7 +4,7 @@ * * GET answers the active method, when it was applied and the last dry run * (status and result: per-account ordinal labels only, never an id). POST - * `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run + * `{ action: "dry_run" | "apply" | "rebuild" }` answers 202 `{ enqueued }`: a dry run * computes both methods for every account and day and writes nothing but its * result; `apply` needs a successful dry run from the last seven days (409 * `maintenance.pulseDayMean.dryRunRequired`), switches the method and @@ -31,6 +31,7 @@ import { PULSE_DAY_MEAN_SEND_OPTIONS, readPulseDayMeanStatus, writeDryRun, + writeRebuild, type PulseDayMeanPayload, } from "@/lib/jobs/pulse-day-mean"; import { annotate } from "@/lib/logging/context"; @@ -41,6 +42,7 @@ import { import { hasFreshDryRun, pulseDayMeanActionSchema, + type PulseDayMeanStatus, } from "@/lib/validations/pulse-day-mean"; export const dynamic = "force-dynamic"; @@ -54,6 +56,33 @@ function busy() { }); } +/** + * Queue the rebuild and record it as queued, so the card shows it from the + * moment it is asked for and a rebuild that never starts reads as failed. + * When another run holds the singleton, the previous rebuild state is put + * back and the answer is false. + */ +async function sendRebuild( + boss: NonNullable>, + previous: PulseDayMeanStatus["lastRebuild"], +): Promise { + await writeRebuild({ + status: "queued", + startedAt: new Date().toISOString(), + finishedAt: null, + accounts: null, + rows: null, + }); + const payload: PulseDayMeanPayload = { action: "rebuild" }; + const jobId = await boss.send( + PULSE_DAY_MEAN_QUEUE, + payload, + PULSE_DAY_MEAN_SEND_OPTIONS, + ); + if (jobId === null) await writeRebuild(previous); + return jobId !== null; +} + export const GET = apiHandler(async () => { await requireAdmin(); const status = await readPulseDayMeanStatus(); @@ -110,6 +139,28 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiSuccess({ enqueued: true }, 202); } + if (action === "rebuild") { + // The recalculation again, after an apply whose rebuild stopped. The + // method is already switched; the dry run that justified it stands. + const status = await readPulseDayMeanStatus(); + if (status.appliedAt === null) { + return apiError( + "Nothing applied yet; run a dry run and apply first", + 409, + { errorCode: "maintenance.pulseDayMean.dryRunRequired" }, + ); + } + const enqueued = await sendRebuild(boss, status.lastRebuild); + if (!enqueued) return busy(); + await auditLog("admin.pulseDayMean.rebuild", { + userId: user.id, + ipAddress: getClientIp(request), + details: { method: status.method }, + }); + annotate({ meta: { pulse_day_mean_enqueued: true } }); + return apiSuccess({ enqueued: true }, 202); + } + if (!hasFreshDryRun(await readPulseDayMeanStatus())) { return apiError( "Run a dry run first; apply needs one finished in the last seven days", @@ -135,13 +186,10 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); setActivePulseDayMeanMethod(APPLIED_METHOD); - const payload: PulseDayMeanPayload = { action: "rebuild" }; - const jobId = await boss.send( - PULSE_DAY_MEAN_QUEUE, - payload, - PULSE_DAY_MEAN_SEND_OPTIONS, + const enqueued = await sendRebuild( + boss, + (await readPulseDayMeanStatus()).lastRebuild, ); - const enqueued = jobId !== null; await auditLog("admin.pulseDayMean.apply", { userId: user.id, diff --git a/src/components/admin/__tests__/pulse-day-mean-card.test.tsx b/src/components/admin/__tests__/pulse-day-mean-card.test.tsx index 926de7ea6..317901ea6 100644 --- a/src/components/admin/__tests__/pulse-day-mean-card.test.tsx +++ b/src/components/admin/__tests__/pulse-day-mean-card.test.tsx @@ -37,7 +37,12 @@ const RESULT = { const status = ( lastDryRun: PulseDayMeanStatus["lastDryRun"], method: PulseDayMeanStatus["method"] = "hour_mean_v1", -): PulseDayMeanStatus => ({ method, appliedAt: null, lastDryRun }); +): PulseDayMeanStatus => ({ + method, + appliedAt: null, + lastDryRun, + lastRebuild: null, +}); const done = (finishedAt: string) => ({ status: "done" as const, @@ -113,4 +118,33 @@ describe("PulseDayMeanCard", () => { expect(de).toContain("Dünne Stunden gewichtet"); expect(de).toContain("Probelauf"); }); + + it("shows a stopped recalculation and offers it again without a dry run", () => { + const failed: PulseDayMeanStatus = { + ...status(done("2026-10-01T10:00:00Z"), "weighted_hour_mean_v2"), + appliedAt: "2026-10-01T11:00:00Z", + lastRebuild: { + status: "failed", + startedAt: "2026-10-01T11:00:00Z", + finishedAt: "2026-10-01T11:05:00Z", + accounts: null, + rows: null, + }, + }; + const html = render(failed); + expect(html).toContain('data-state="failed"'); + expect(html).toContain('data-testid="admin-pulse-day-mean-rebuild"'); + + const finished = render({ + ...failed, + lastRebuild: { + ...failed.lastRebuild!, + status: "done", + accounts: 3, + rows: 900, + }, + }); + expect(finished).not.toContain("admin-pulse-day-mean-rebuild"); + expect(finished).toContain('data-slot="pulse-day-mean-rebuild"'); + }); }); diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx index 285fec109..91bfbc0dc 100644 --- a/src/components/admin/pulse-day-mean-card.tsx +++ b/src/components/admin/pulse-day-mean-card.tsx @@ -57,8 +57,13 @@ export function PulseDayMeanCard() { queryFn: () => apiGet(ENDPOINT, { credentials: "include" }), refetchInterval: (query) => { - const state = query.state.data?.lastDryRun?.status; - return state === "queued" || state === "running" ? POLL_MS : false; + const states = [ + query.state.data?.lastDryRun?.status, + query.state.data?.lastRebuild?.status, + ]; + return states.some((state) => state === "queued" || state === "running") + ? POLL_MS + : false; }, }); @@ -101,6 +106,8 @@ export function PulseDayMeanCard() { ? t("admin.section.pulseDayMean.applied") : t("admin.section.pulseDayMean.appliedNoRebuild"), ); + } else if (action === "rebuild") { + toast.success(t("admin.section.pulseDayMean.rebuildStarted")); } else { toast.success(t("admin.section.pulseDayMean.dryRunStarted")); } @@ -125,9 +132,16 @@ export function PulseDayMeanCard() { const s = status.data; const dryRun = s?.lastDryRun ?? null; const result = dryRun?.status === "done" ? dryRun.result : null; - const active = dryRun?.status === "queued" || dryRun?.status === "running"; + const rebuild = s?.lastRebuild ?? null; + const rebuilding = + rebuild?.status === "queued" || rebuild?.status === "running"; + const active = + dryRun?.status === "queued" || dryRun?.status === "running" || rebuilding; const weighted = s?.method === "weighted_hour_mean_v2"; const canApply = !!s && !active && hasFreshDryRun(s); + // A stopped recalculation is run again from here; the method is already + // switched, so it needs no new dry run. + const canRebuild = !!s?.appliedAt && rebuild?.status === "failed"; return ( @@ -252,6 +266,25 @@ export function PulseDayMeanCard() {

) : null} + {rebuild ? ( +

+ {rebuild.status === "done" + ? t("admin.section.pulseDayMean.rebuild.done", { + at: fmt.dateTime(rebuild.finishedAt ?? rebuild.startedAt), + accounts: fmt.number(rebuild.accounts ?? 0), + }) + : t(`admin.section.pulseDayMean.rebuild.${rebuild.status}`)} +

+ ) : null} + + {canRebuild ? ( + + ) : null}