("/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/__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/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/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/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/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();
}
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/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/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/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/jobs/__tests__/pulse-day-mean-rebuild-state.test.ts b/src/lib/jobs/__tests__/pulse-day-mean-rebuild-state.test.ts
new file mode 100644
index 000000000..1873d5653
--- /dev/null
+++ b/src/lib/jobs/__tests__/pulse-day-mean-rebuild-state.test.ts
@@ -0,0 +1,73 @@
+/**
+ * The pulse rollup rebuild is on the record: a stopped one reads as failed
+ * beside the dry run, so the admin card can offer it again.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const db = vi.hoisted(() => {
+ let stored: unknown = {
+ status: "done",
+ startedAt: "2026-10-15T08:00:00.000Z",
+ finishedAt: "2026-10-15T08:10:00.000Z",
+ result: null,
+ };
+ return {
+ get stored() {
+ return stored;
+ },
+ reset(value: unknown) {
+ stored = value;
+ },
+ appSettings: {
+ findUnique: vi.fn(async () => ({ pulseDayMeanDryRunJson: stored })),
+ upsert: vi.fn(
+ async (args: { update: { pulseDayMeanDryRunJson: unknown } }) => {
+ stored = args.update.pulseDayMeanDryRunJson;
+ return {};
+ },
+ ),
+ },
+ $queryRaw: vi.fn(),
+ };
+});
+
+vi.mock("@/lib/db", () => ({
+ prisma: { appSettings: db.appSettings, $queryRaw: db.$queryRaw },
+}));
+vi.mock("@/lib/jobs/job-lock", () => ({
+ withJobLock: async (_key: string, run: () => Promise) => ({
+ ran: true,
+ result: await run(),
+ }),
+}));
+vi.mock("@/lib/logging/signal", () => ({ logCaught: vi.fn() }));
+
+import { handlePulseDayMean } from "../pulse-day-mean";
+
+const job = [{ data: { action: "rebuild" } }] as never;
+
+beforeEach(() => {
+ db.$queryRaw.mockReset();
+});
+
+describe("the pulse rebuild state", () => {
+ it("records a stopped rebuild as failed, beside the dry run", async () => {
+ db.$queryRaw.mockRejectedValue(new Error("statement timeout"));
+ const outcome = await handlePulseDayMean(job);
+ expect(outcome.ok).toBe(false);
+ expect(db.stored).toMatchObject({
+ status: "done",
+ rebuild: { status: "failed", accounts: null },
+ });
+ });
+
+ it("records a finished rebuild with what it did", async () => {
+ // No account holds pulse: done, nothing written.
+ db.$queryRaw.mockResolvedValue([]);
+ const outcome = await handlePulseDayMean(job);
+ expect(outcome.ok).toBe(true);
+ expect(db.stored).toMatchObject({
+ rebuild: { status: "done", accounts: 0, rows: 0 },
+ });
+ });
+});
diff --git a/src/lib/jobs/pulse-day-mean.ts b/src/lib/jobs/pulse-day-mean.ts
index f90d4a3a6..c5f9bc758 100644
--- a/src/lib/jobs/pulse-day-mean.ts
+++ b/src/lib/jobs/pulse-day-mean.ts
@@ -41,10 +41,12 @@ import {
} from "@/lib/rollups/measurement-rollups";
import {
PULSE_DAY_MEAN_CHANGE_BUCKETS,
- pulseDayMeanDryRunSchema,
+ pulseDayMeanStoredRunsSchema,
type PulseDayMeanDryRun,
type PulseDayMeanDryRunResult,
+ type PulseDayMeanRebuild,
type PulseDayMeanStatus,
+ type PulseDayMeanStoredRuns,
} from "@/lib/validations/pulse-day-mean";
export const PULSE_DAY_MEAN_QUEUE = "pulse-day-mean";
@@ -92,15 +94,26 @@ export async function isPulseDayMeanRunActive(): Promise {
}
}
-function parseDryRun(value: unknown): PulseDayMeanDryRun | null {
- const parsed = pulseDayMeanDryRunSchema.safeParse(value);
+function parseStoredRuns(value: unknown): PulseDayMeanStoredRuns | null {
+ const parsed = pulseDayMeanStoredRunsSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
+/** A run still marked queued or running with no live job behind it failed. */
+function settle(
+ run: T | null,
+ active: boolean,
+): T | null {
+ if (run && (run.status === "queued" || run.status === "running") && !active) {
+ return { ...run, status: "failed" };
+ }
+ return run;
+}
+
/**
- * The stored method, when it was applied, and the last dry run. A dry run
- * still marked queued or running with no live job behind it (the worker went
- * away mid-run) reads as failed.
+ * The stored method, when it was applied, the last dry run and the last
+ * rebuild. A run still marked queued or running with no live job behind it
+ * (the worker went away mid-run) reads as failed.
*/
export async function readPulseDayMeanStatus(): Promise {
const row = await prisma.appSettings.findUnique({
@@ -111,32 +124,59 @@ export async function readPulseDayMeanStatus(): Promise {
pulseDayMeanDryRunJson: true,
},
});
- let lastDryRun = parseDryRun(row?.pulseDayMeanDryRunJson ?? null);
- if (
- lastDryRun &&
- (lastDryRun.status === "queued" || lastDryRun.status === "running") &&
- !(await isPulseDayMeanRunActive())
- ) {
- lastDryRun = { ...lastDryRun, status: "failed" };
- }
+ const stored = parseStoredRuns(row?.pulseDayMeanDryRunJson ?? null);
+ const pending =
+ stored !== null &&
+ [stored.status, stored.rebuild?.status].some(
+ (status) => status === "queued" || status === "running",
+ );
+ const active = pending ? await isPulseDayMeanRunActive() : false;
+ const { rebuild, ...dryRun } = stored ?? { rebuild: null };
return {
method: isPulseDayMeanMethod(row?.pulseDayMeanMethod)
? row.pulseDayMeanMethod
: DEFAULT_PULSE_DAY_MEAN_METHOD,
appliedAt: row?.pulseDayMeanAppliedAt?.toISOString() ?? null,
- lastDryRun,
+ lastDryRun: stored ? settle(dryRun as PulseDayMeanDryRun, active) : null,
+ lastRebuild: settle(rebuild ?? null, active),
};
}
-/** The only write a dry run makes. */
-export async function writeDryRun(run: PulseDayMeanDryRun): Promise {
+async function readStoredRuns(): Promise {
+ const row = await prisma.appSettings.findUnique({
+ where: { id: "singleton" },
+ select: { pulseDayMeanDryRunJson: true },
+ });
+ return parseStoredRuns(row?.pulseDayMeanDryRunJson ?? null);
+}
+
+async function writeStoredRuns(runs: PulseDayMeanStoredRuns): Promise {
await prisma.appSettings.upsert({
where: { id: "singleton" },
- create: { id: "singleton", pulseDayMeanDryRunJson: run },
- update: { pulseDayMeanDryRunJson: run },
+ create: { id: "singleton", pulseDayMeanDryRunJson: runs },
+ update: { pulseDayMeanDryRunJson: runs },
});
}
+/** The only write a dry run makes. The last rebuild stays as it was. */
+export async function writeDryRun(run: PulseDayMeanDryRun): Promise {
+ const stored = await readStoredRuns();
+ await writeStoredRuns({ ...run, rebuild: stored?.rebuild ?? null });
+}
+
+/**
+ * Record the rebuild's state beside the dry run it followed. A rebuild only
+ * runs after an apply, and an apply needs a dry run, so there is always one
+ * to sit beside; without it there is nothing to record against.
+ */
+export async function writeRebuild(
+ rebuild: PulseDayMeanRebuild | null,
+): Promise {
+ const stored = await readStoredRuns();
+ if (!stored) return;
+ await writeStoredRuns({ ...stored, rebuild });
+}
+
// ─── Accounts ─────────────────────────────────────────────
interface PulseAccount {
@@ -354,6 +394,38 @@ export async function rebuildPulseRollups(): Promise<{
// ─── Job ──────────────────────────────────────────────────
+async function runRebuild(): Promise<{ accounts: number; rows: number }> {
+ const startedAt = new Date().toISOString();
+ await writeRebuild({
+ status: "running",
+ startedAt,
+ finishedAt: null,
+ accounts: null,
+ rows: null,
+ });
+ try {
+ const done = await rebuildPulseRollups();
+ await writeRebuild({
+ status: "done",
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ ...done,
+ });
+ return done;
+ } catch (err) {
+ await writeRebuild({
+ status: "failed",
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ accounts: null,
+ rows: null,
+ }).catch((writeErr) =>
+ logCaught("pulse_day_mean.rebuild.status_write_failed", writeErr),
+ );
+ throw err;
+ }
+}
+
async function runDryRun(): Promise {
const startedAt = new Date().toISOString();
await writeDryRun({
@@ -394,7 +466,7 @@ export async function handlePulseDayMean(
try {
const guarded = await withJobLock(PULSE_DAY_MEAN_QUEUE, async () =>
action === "rebuild"
- ? { kind: "rebuild" as const, ...(await rebuildPulseRollups()) }
+ ? { kind: "rebuild" as const, ...(await runRebuild()) }
: { kind: "dry_run" as const, result: await runDryRun() },
);
if (!guarded.ran) {
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/__tests__/intraday-hour-buckets.test.ts b/src/lib/measurements/__tests__/intraday-hour-buckets.test.ts
index e7008f973..53eda2b47 100644
--- a/src/lib/measurements/__tests__/intraday-hour-buckets.test.ts
+++ b/src/lib/measurements/__tests__/intraday-hour-buckets.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
classifyHourBucket,
+ combineHourWrites,
cumulativeFromIncrements,
HOUR_NOT_POSTED,
isStartOfLocalHour,
@@ -158,3 +159,38 @@ describe("cumulativeFromIncrements", () => {
expect(postedHours(increments)).toBe(3);
});
});
+
+describe("combineHourWrites", () => {
+ const entry = (index: number, startIso: string, value: number) => ({
+ index,
+ type: "ACTIVITY_STEPS" as const,
+ hkIdentifier: "HKQuantityTypeIdentifierStepCount",
+ dateKey: "2026-10-25",
+ hour: 2,
+ value,
+ startMs: Date.parse(startIso),
+ });
+
+ it("sums the two starts of the hour a fall-back day repeats", () => {
+ // Berlin, 2026-10-25: 02:00 CEST is 00:00Z, 02:00 CET is 01:00Z.
+ const { writes, superseded } = combineHourWrites([
+ entry(0, "2026-10-25T00:00:00Z", 300),
+ entry(1, "2026-10-25T01:00:00Z", 120),
+ ]);
+ expect(superseded).toEqual([]);
+ expect(writes).toHaveLength(1);
+ expect(writes[0].write.value).toBe(420);
+ expect(writes[0].contributors.sort()).toEqual([0, 1]);
+ });
+
+ it("lets the last entry of one start win, and marks the earlier one", () => {
+ const { writes, superseded } = combineHourWrites([
+ entry(0, "2026-10-25T00:00:00Z", 300),
+ entry(1, "2026-10-25T00:00:00Z", 310),
+ entry(2, "2026-10-25T01:00:00Z", 120),
+ ]);
+ expect(superseded).toEqual([0]);
+ expect(writes[0].write.value).toBe(430);
+ expect(writes[0].write.index).toBe(2);
+ });
+});
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 =
diff --git a/src/lib/measurements/intraday-hour-buckets.ts b/src/lib/measurements/intraday-hour-buckets.ts
index 0c9940b29..2bd5a0830 100644
--- a/src/lib/measurements/intraday-hour-buckets.ts
+++ b/src/lib/measurements/intraday-hour-buckets.ts
@@ -177,6 +177,59 @@ export interface HourBucketWrite {
value: number;
}
+/** One hour as the batch reads it, with the instant it starts at. */
+export interface HourBucketEntry extends HourBucketWrite {
+ /** `startDate` of the entry, in epoch milliseconds. */
+ startMs: number;
+}
+
+/**
+ * A batch's hours reduced to one write per slot.
+ *
+ * A slot is one local hour of one day. On the autumn day the clock falls
+ * back, one local hour happens twice, and the phone hands over both under
+ * the same key (`…T02`), each with its own `startDate`: they are two hours of
+ * activity and are summed into the slot rather than the second replacing the
+ * first. Entries that repeat the same start are the same hour posted twice,
+ * and the last one counts, as for every other slot.
+ *
+ * `contributors` are the entries whose values the write carries (they take
+ * its status); `superseded` are the earlier repeats of one start.
+ *
+ * The slot holds one number, so the sum is only as complete as the batch
+ * that carries it: both occurrences have to arrive together. The phone posts
+ * a day's hours in one batch, and a later batch that carries only one of
+ * them replaces the slot with that one, as a re-post of any slot does.
+ */
+export function combineHourWrites(entries: readonly HourBucketEntry[]): {
+ writes: Array<{ write: HourBucketWrite; contributors: number[] }>;
+ superseded: number[];
+} {
+ const slots = new Map>();
+ const superseded: number[] = [];
+ for (const entry of entries) {
+ const key = `${entry.type}::${entry.dateKey}::${entry.hour}`;
+ const byStart = slots.get(key) ?? new Map();
+ const earlier = byStart.get(entry.startMs);
+ if (earlier) superseded.push(earlier.index);
+ byStart.set(entry.startMs, entry);
+ slots.set(key, byStart);
+ }
+ const writes = [...slots.values()].map((byStart) => {
+ const occurrences = [...byStart.values()];
+ const last = occurrences.reduce((a, b) => (b.index > a.index ? b : a));
+ const { startMs: _start, ...base } = last;
+ return {
+ write: {
+ ...base,
+ value: occurrences.reduce((sum, entry) => sum + entry.value, 0),
+ },
+ contributors: occurrences.map((entry) => entry.index),
+ };
+ });
+ return { writes, superseded };
+}
+
/**
* Store a batch's hours, one transaction per (type, day). Returns the
* per-entry status: `inserted` for the first value of a slot, `updated` when
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..312073ad2
--- /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 (..._args: unknown[]) => "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/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);
}
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
diff --git a/src/lib/openapi/routes/life-circumstances.ts b/src/lib/openapi/routes/life-circumstances.ts
index 590dcabd3..4e92b11e6 100644
--- a/src/lib/openapi/routes/life-circumstances.ts
+++ b/src/lib/openapi/routes/life-circumstances.ts
@@ -119,7 +119,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({
@@ -129,7 +133,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(
@@ -268,7 +276,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..f9c02127d 100644
--- a/src/lib/profile/life-circumstances.ts
+++ b/src/lib/profile/life-circumstances.ts
@@ -13,8 +13,15 @@
* Server-only.
*/
import { Prisma } from "@/generated/prisma/client";
-import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
+import { 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 {
@@ -40,6 +47,7 @@ import {
computePackYears,
DEFAULT_FREE_WEEKDAYS,
isReviewDue,
+ LIFE_KIND_AI_SECTION,
screeningHints,
type FreeWeekdaysSpan,
type PackYears,
@@ -63,22 +71,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 +157,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 },
@@ -243,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,
@@ -327,17 +335,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 +405,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),
},
@@ -438,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
@@ -452,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 },
@@ -646,11 +673,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/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);
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/src/lib/validations/pulse-day-mean.ts b/src/lib/validations/pulse-day-mean.ts
index f83a65269..5a0c4127b 100644
--- a/src/lib/validations/pulse-day-mean.ts
+++ b/src/lib/validations/pulse-day-mean.ts
@@ -9,7 +9,9 @@ import { z } from "zod/v4";
import type { PulseDayMeanMethod } from "@/lib/measurements/day-statistic";
export const pulseDayMeanActionSchema = z.strictObject({
- action: z.enum(["dry_run", "apply"]),
+ // `rebuild` runs the recalculation again after an apply whose rebuild
+ // stopped, without a new dry run: the method is already switched.
+ action: z.enum(["dry_run", "apply", "rebuild"]),
});
export type PulseDayMeanActionInput = z.infer;
@@ -46,20 +48,47 @@ const dryRunResultSchema = z.object({
});
export type PulseDayMeanDryRunResult = z.infer;
-/** `app_settings.pulse_day_mean_dry_run_json`: the last dry run. */
-export const pulseDayMeanDryRunSchema = z.object({
- status: z.enum(["queued", "running", "done", "failed"]),
+const runStatusSchema = z.enum(["queued", "running", "done", "failed"]);
+
+/** The last dry run. */
+const pulseDayMeanDryRunSchema = z.object({
+ status: runStatusSchema,
startedAt: z.string(),
finishedAt: z.string().nullable(),
result: dryRunResultSchema.nullable(),
});
export type PulseDayMeanDryRun = z.infer;
+/** The last recalculation of the pulse rollups after an apply. */
+const pulseDayMeanRebuildSchema = z.object({
+ status: runStatusSchema,
+ startedAt: z.string(),
+ finishedAt: z.string().nullable(),
+ /** Accounts and rollup rows done; null until the run has finished. */
+ accounts: z.number().int().nonnegative().nullable(),
+ rows: z.number().int().nonnegative().nullable(),
+});
+export type PulseDayMeanRebuild = z.infer;
+
+/**
+ * `app_settings.pulse_day_mean_dry_run_json`: the last dry run, and beside it
+ * the last rebuild. One column holds both, because the rebuild only ever
+ * follows an apply that needed a dry run; a blob written before the rebuild
+ * was recorded has no `rebuild` key and reads as none.
+ */
+export const pulseDayMeanStoredRunsSchema = pulseDayMeanDryRunSchema.extend({
+ rebuild: pulseDayMeanRebuildSchema.nullable().optional(),
+});
+export type PulseDayMeanStoredRuns = z.infer<
+ typeof pulseDayMeanStoredRunsSchema
+>;
+
/** `GET /api/admin/maintenance/pulse-day-mean`. */
export interface PulseDayMeanStatus {
method: PulseDayMeanMethod;
appliedAt: string | null;
lastDryRun: PulseDayMeanDryRun | null;
+ lastRebuild: PulseDayMeanRebuild | null;
}
/** How old a finished dry run may be for the apply step to accept it. */
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([]);
+ });
+});
diff --git a/tests/integration/life-circumstances.test.ts b/tests/integration/life-circumstances.test.ts
index fa5088705..508c8a53c 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");
@@ -396,6 +424,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);
@@ -477,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);
diff --git a/tests/integration/pulse-day-mean-method.test.ts b/tests/integration/pulse-day-mean-method.test.ts
index 9aa1dff77..822b16dd1 100644
--- a/tests/integration/pulse-day-mean-method.test.ts
+++ b/tests/integration/pulse-day-mean-method.test.ts
@@ -38,7 +38,7 @@ import { readLiveBuckets } from "@/lib/measurements/daily-series-read";
import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
-import { pulseDayMeanDryRunSchema } from "@/lib/validations/pulse-day-mean";
+import { pulseDayMeanStoredRunsSchema } from "@/lib/validations/pulse-day-mean";
import type { Job } from "pg-boss";
const prisma = getPrismaClient();
@@ -276,7 +276,9 @@ describe("the dry run", () => {
const stored = await prisma.appSettings.findUniqueOrThrow({
where: { id: "singleton" },
});
- const run = pulseDayMeanDryRunSchema.parse(stored.pulseDayMeanDryRunJson);
+ const run = pulseDayMeanStoredRunsSchema.parse(
+ stored.pulseDayMeanDryRunJson,
+ );
expect(run.status).toBe("done");
expect(run.finishedAt).not.toBeNull();
expect(run.result).toEqual({
@@ -324,6 +326,13 @@ describe("the rebuild after apply", () => {
});
const outcome = await handlePulseDayMean(job("rebuild"));
expect(outcome.ok).toBe(true);
+ // The rebuild is on the record beside the dry run, with what it did.
+ const { readPulseDayMeanStatus } =
+ await import("@/lib/jobs/pulse-day-mean");
+ const status = await readPulseDayMeanStatus();
+ expect(status.lastRebuild).toMatchObject({ status: "done" });
+ expect(status.lastRebuild?.accounts).toBeGreaterThan(0);
+ expect(status.lastDryRun?.status).toBe("done");
expect(await dayMean(A, D1)).toBeCloseTo(V2, 6);
expect(await dayMean(A, D2)).toBeCloseTo(70, 6);
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]);
+ });
+});