Merge branch 'release/v1.46.0' into feat/v146-ui

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 18:30:00 +02:00
commit 4748091636
156 files changed
+2819 -1881

No files matched your search

+7
View File
@@ -6585,6 +6585,13 @@ paths:
schema:
$ref: "#/components/schemas/CreateMedicationRequest"
responses:
"200":
description: "A mirror create re-posting an `(externalSource, externalId)` pair the caller already holds: the existing
medication, nothing written."
content:
application/json:
schema:
$ref: "#/components/schemas/CreateMedicationResponse"
"201":
description: Created medication with its schedules.
content:
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} wollte eine Einnahme ändern und wurde abgewiesen",
"rejectedDoseImport": "{name} wollte Einnahmen importieren und wurde abgewiesen",
"rejectedTrackedValue": "{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen",
"rejectedTrackedValueTypeCreate": "{name} wollte eine eigene Messgröße anlegen und wurde abgewiesen",
"rejectedTrackedValueTypeUpdate": "{name} wollte eine eigene Messgröße ändern und wurde abgewiesen",
"rejectedMedicationLayout": "{name} wollte die Medikamentenliste neu anordnen und wurde abgewiesen",
"deniedDoseImport": "Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden",
"anamnesisFactCreate": "{name} hat einen Fakt zur Vorgeschichte eingetragen",
"customMetricCreate": "{name} hat eine eigene Messgröße angelegt",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} tried to change a dose and was refused",
"rejectedDoseImport": "{name} tried to import doses and was refused",
"rejectedTrackedValue": "{name} tried to log a tracked value and was refused",
"rejectedTrackedValueTypeCreate": "{name} tried to add a tracked value type and was refused",
"rejectedTrackedValueTypeUpdate": "{name} tried to change a tracked value type and was refused",
"rejectedMedicationLayout": "{name} tried to rearrange the medication list and was refused",
"deniedDoseImport": "A dose import {name} started could not be queued",
"anamnesisFactCreate": "{name} added a health background fact",
"customMetricCreate": "{name} added a tracked value type",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} intentó cambiar una dosis y fue rechazado",
"rejectedDoseImport": "{name} intentó importar dosis y fue rechazado",
"rejectedTrackedValue": "{name} intentó registrar un valor propio y fue rechazado",
"rejectedTrackedValueTypeCreate": "{name} intentó añadir un tipo de valor propio y fue rechazado",
"rejectedTrackedValueTypeUpdate": "{name} intentó cambiar un tipo de valor propio y fue rechazado",
"rejectedMedicationLayout": "{name} intentó reordenar la lista de medicamentos y fue rechazado",
"deniedDoseImport": "Una importación de dosis iniciada por {name} no se pudo poner en cola",
"anamnesisFactCreate": "{name} añadió un dato de antecedentes de salud",
"customMetricCreate": "{name} añadió un tipo de valor propio",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} a tenté de modifier une prise et a été refusé",
"rejectedDoseImport": "{name} a tenté d'importer des prises et a été refusé",
"rejectedTrackedValue": "{name} a tenté d'enregistrer une valeur personnalisée et a été refusé",
"rejectedTrackedValueTypeCreate": "{name} a tenté d'ajouter un type de valeur personnalisée et a été refusé",
"rejectedTrackedValueTypeUpdate": "{name} a tenté de modifier un type de valeur personnalisée et a été refusé",
"rejectedMedicationLayout": "{name} a tenté de réorganiser la liste des médicaments et a été refusé",
"deniedDoseImport": "Un import de prises lancé par {name} n'a pas pu être mis en file",
"anamnesisFactCreate": "{name} a ajouté un élément d'antécédents de santé",
"customMetricCreate": "{name} a ajouté un type de valeur personnalisée",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} ha provato a modificare una dose ed è stato rifiutato",
"rejectedDoseImport": "{name} ha provato a importare delle dosi ed è stato rifiutato",
"rejectedTrackedValue": "{name} ha provato a registrare un valore personalizzato ed è stato rifiutato",
"rejectedTrackedValueTypeCreate": "{name} ha provato ad aggiungere un tipo di valore personalizzato ed è stato rifiutato",
"rejectedTrackedValueTypeUpdate": "{name} ha provato a modificare un tipo di valore personalizzato ed è stato rifiutato",
"rejectedMedicationLayout": "{name} ha provato a riordinare l'elenco dei farmaci ed è stato rifiutato",
"deniedDoseImport": "Un'importazione di dosi avviata da {name} non è stata messa in coda",
"anamnesisFactCreate": "{name} ha aggiunto un dato di anamnesi",
"customMetricCreate": "{name} ha aggiunto un tipo di valore personalizzato",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} 님이 복용 기록을 수정하려다 거절됐어요",
"rejectedDoseImport": "{name} 님이 복용 기록을 불러오려다 거절됐어요",
"rejectedTrackedValue": "{name} 님이 추적 값을 기록하려다 거절됐어요",
"rejectedTrackedValueTypeCreate": "{name} 님이 추적 값 종류를 추가하려다 거절됐어요",
"rejectedTrackedValueTypeUpdate": "{name} 님이 추적 값 종류를 수정하려다 거절됐어요",
"rejectedMedicationLayout": "{name} 님이 약 목록 순서를 바꾸려다 거절됐어요",
"deniedDoseImport": "{name} 님이 시작한 복용 기록 불러오기를 대기열에 넣지 못했어요",
"anamnesisFactCreate": "{name} 님이 건강 배경 항목을 추가했어요",
"customMetricCreate": "{name} 님이 추적 값 종류를 추가했어요",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} probeerde een dosis te wijzigen, maar dat werd geweigerd",
"rejectedDoseImport": "{name} probeerde doses te importeren, maar dat werd geweigerd",
"rejectedTrackedValue": "{name} probeerde een bijgehouden waarde vast te leggen, maar dat werd geweigerd",
"rejectedTrackedValueTypeCreate": "{name} probeerde een type bijgehouden waarde toe te voegen, maar dat werd geweigerd",
"rejectedTrackedValueTypeUpdate": "{name} probeerde een type bijgehouden waarde te wijzigen, maar dat werd geweigerd",
"rejectedMedicationLayout": "{name} probeerde de medicijnlijst anders te ordenen, maar dat werd geweigerd",
"deniedDoseImport": "Een dosisimport die {name} had gestart, kon niet in de wachtrij worden gezet",
"anamnesisFactCreate": "{name} heeft een gegeven aan de gezondheidsachtergrond toegevoegd",
"customMetricCreate": "{name} heeft een type bijgehouden waarde toegevoegd",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} próbował zmienić przyjęcie dawki i został odrzucony",
"rejectedDoseImport": "{name} próbował zaimportować przyjęcia dawek i został odrzucony",
"rejectedTrackedValue": "{name} próbował zapisać własny pomiar i został odrzucony",
"rejectedTrackedValueTypeCreate": "{name} próbował dodać własny typ pomiaru i został odrzucony",
"rejectedTrackedValueTypeUpdate": "{name} próbował zmienić własny typ pomiaru i został odrzucony",
"rejectedMedicationLayout": "{name} próbował zmienić kolejność listy leków i został odrzucony",
"deniedDoseImport": "Import przyjęć rozpoczęty przez {name} nie mógł zostać zakolejkowany",
"anamnesisFactCreate": "{name} dodał fakt z wywiadu zdrowotnego",
"customMetricCreate": "{name} dodał własny typ pomiaru",
+3
View File
@@ -12719,6 +12719,9 @@
"rejectedDoseUpdate": "{name} tentou alterar uma dose e foi recusado",
"rejectedDoseImport": "{name} tentou importar doses e foi recusado",
"rejectedTrackedValue": "{name} tentou registrar um valor acompanhado e foi recusado",
"rejectedTrackedValueTypeCreate": "{name} tentou adicionar um tipo de valor acompanhado e foi recusado",
"rejectedTrackedValueTypeUpdate": "{name} tentou alterar um tipo de valor acompanhado e foi recusado",
"rejectedMedicationLayout": "{name} tentou reorganizar a lista de medicamentos e foi recusado",
"deniedDoseImport": "Uma importação de doses que {name} iniciou não pôde ser enfileirada",
"anamnesisFactCreate": "{name} adicionou um fato do histórico de saúde",
"customMetricCreate": "{name} adicionou um tipo de valor acompanhado",
+6 -12
View File
@@ -24,7 +24,7 @@ overrides:
proxy-addr@<2.0.8: ^2.0.8
mysql2@<3.22.0: ^3.22.0
minimatch@<3.1.4: ^3.1.4
ip-address@<10.7.1: ^10.7.1
ip-address@<10.7.4: ^10.7.4
sharp@<0.35.0: ^0.35.3
pdfjs-dist@>=5.6.83 <6.2.108: 6.2.108
deepmerge-ts@<8.0.0: ^8.0.0
@@ -4891,12 +4891,8 @@ packages:
iobuffer@5.4.0:
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
ip-address@10.7.2:
resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
engines: {node: '>= 12'}
ip-address@10.7.3:
resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==}
ip-address@10.7.4:
resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
@@ -11875,7 +11871,7 @@ snapshots:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
express: 5.2.1(supports-color@7.2.0)
ip-address: 10.7.2
ip-address: 10.7.4
transitivePeerDependencies:
- supports-color
@@ -12285,9 +12281,7 @@ snapshots:
iobuffer@5.4.0: {}
ip-address@10.7.2: {}
ip-address@10.7.3: {}
ip-address@10.7.4: {}
ipaddr.js@1.9.1: {}
@@ -14024,7 +14018,7 @@ snapshots:
socks@2.8.10:
dependencies:
ip-address: 10.7.3
ip-address: 10.7.4
smart-buffer: 4.2.0
sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
+3 -2
View File
@@ -65,8 +65,9 @@ overrides:
# 10.0.0.1 rather than being rejected. That is the exact alt-notation class
# `isPublicUrl` exists to catch, and this copy arrives under the MCP SDK's
# rate limiter where nothing of ours screens it.
# Later advisories up to 10.7.0 raised the floor from 10.3.1 to 10.7.1.
"ip-address@<10.7.1": "^10.7.1"
# Later advisories raised the floor from 10.3.1 to 10.7.1, then to 10.7.4
# (a ReDoS in the Address6 zone identifier, through 10.7.3).
"ip-address@<10.7.4": "^10.7.4"
"sharp@<0.35.0": "^0.35.3"
# A crafted PDF could run arbitrary JavaScript through the renderer. Our own
# copy is pinned past it in `devDependencies`, but a second copy arrives
@@ -0,0 +1,63 @@
/**
* Structural guard: audit rows are written through `auditLog()`.
*
* `auditLog` decides two things a bare `prisma.auditLog.create` cannot: who
* acted when the row is not theirs (the delegate on a shared record), and
* whether a refused attempt (`<surface>.validation-failed`) becomes a row
* at all. An owner's own refused request stays on the wide event; only a
* refusal somebody else caused reaches the ledger the owner's activity feed
* reads. Twelve routes wrote their refusal row directly and filed every
* malformed body an owner's app sent, a few per sync, with no actor on a
* delegate's.
*
* Whitespace-tolerant: the calls were written across two lines
* (`prisma.auditLog` then `.create(`).
*/
import { describe, expect, it } from "vitest";
import { readSourceFolder, stripComments } from "./helpers/source-files";
/** Files that write the table directly, and why. */
const DIRECT_WRITERS: Record<string, string> = {
"lib/auth/audit.ts": "the one writer",
"lib/insights/briefing-failure-marker.ts":
"a dated failure marker the briefing read looks up again, not an audit of an act",
};
const RAW_WRITE = /\bauditLog\s*\.\s*create(?:Many(?:AndReturn)?)?\s*\(/;
describe("audit rows go through auditLog()", () => {
const files = readSourceFolder("src", {
floor: 3000,
include: (rel) =>
!rel.startsWith("generated/") &&
!rel.includes("__tests__/") &&
!/\.test\.tsx?$/.test(rel),
});
const writers = files
.filter(([, text]) => RAW_WRITE.test(stripComments(text)))
.map(([rel]) => rel);
it("finds the writers it polices", () => {
expect(files.length).toBeGreaterThan(3000);
expect(writers.length).toBeGreaterThanOrEqual(2);
expect(writers).toContain("lib/auth/audit.ts");
});
it("no other file writes the audit table directly", () => {
expect(writers.filter((rel) => !(rel in DIRECT_WRITERS))).toEqual([]);
});
it("lists no file that stopped writing it", () => {
expect(
Object.keys(DIRECT_WRITERS).filter((rel) => !writers.includes(rel)),
).toEqual([]);
});
it("the matcher reads a call split across lines", () => {
expect(RAW_WRITE.test("prisma.auditLog\n .create({ data })")).toBe(
true,
);
expect(RAW_WRITE.test('auditLog("x.validation-failed", {})')).toBe(false);
});
});
@@ -145,9 +145,10 @@ describe("background-task settle contract", () => {
it("registers what fireAndForget starts", async () => {
let release: () => void = () => {};
fireAndForget(
new Promise<void>((resolve) => {
release = resolve;
}),
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
{ action: "guard.background.registered" },
);
expect(pendingBackgroundTaskCount()).toBe(1);
@@ -161,17 +162,19 @@ describe("background-task settle contract", () => {
let innerDone = false;
fireAndForget(
Promise.resolve().then(() => {
fireAndForget(
new Promise<void>((resolve) =>
setTimeout(() => {
innerDone = true;
resolve();
}, 10),
),
{ action: "guard.background.cascade.inner" },
);
}),
() =>
Promise.resolve().then(() => {
fireAndForget(
() =>
new Promise<void>((resolve) =>
setTimeout(() => {
innerDone = true;
resolve();
}, 10),
),
{ action: "guard.background.cascade.inner" },
);
}),
{ action: "guard.background.cascade.outer" },
);
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 91,
"total": 101359,
"total": 101342,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -38,7 +38,7 @@
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1248,
"lib/auth/session.ts": 811,
"lib/cache/server-cache.ts": 851,
"lib/cache/server-cache.ts": 848,
"lib/cycle/prediction.ts": 883,
"lib/daily/digest.ts": 1317,
"lib/daily/load-digest.ts": 1000,
@@ -82,7 +82,7 @@
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 978,
"lib/medications/scheduling/recurrence.ts": 1108,
"lib/medications/scheduling/slot-upsert.ts": 926,
"lib/medications/scheduling/slot-upsert.ts": 912,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/sync.ts": 900,
"lib/rollups/measurement-rollups.ts": 1496,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 355,
"total": 114858,
"total": 114773,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -15,16 +15,16 @@
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
"app/api/devices/route.ts :: apiHandler(arg)": 231,
"app/api/devices/route.ts :: apiHandler(arg)": 226,
"app/api/documents/inbound/bulk/route.ts :: apiHandler(arg)": 207,
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
"app/api/environment/route.ts :: apiHandler(arg)": 201,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 267,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 262,
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 285,
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 280,
"app/api/labs/route.ts :: postLabResult": 242,
"app/api/mcp/oauth/token/route.ts :: POST": 250,
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
@@ -346,7 +346,7 @@
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
"lib/personal-records/pr-direction.ts :: getPRDirection": 202,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 395,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 325,
"lib/strava/sync.ts :: runStravaSync": 219,
"lib/support/report.ts :: collectSupportReportFacts": 332,
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
+93
View File
@@ -0,0 +1,93 @@
/**
* Structural guard: work a request starts and does not wait for is started
* through `runDetached`, so it cannot rename the request's wide event.
*
* Detached work runs inside the request's context. Before `runDetached`, an
* `annotate({ action })` from it landed on the request's event whenever it
* ran before the event was emitted: the measurement tail's status re-warm
* logged a hand-entered reading as `insights.status.invalidate.skipped` on
* some requests and as `measurement.create` on others.
*
* Two shapes start detached work. `fireAndForget` takes a function and
* starts it detached itself (its signature makes a running promise a type
* error). `trackBackgroundTask` registers a handle, so every call outside
* the logging module has to build that handle with `runDetached`, unless the
* file is listed below with the reason no request can be its caller.
*/
import { describe, expect, it } from "vitest";
import { readSourceFolder, stripComments } from "./helpers/source-files";
/** Files whose detached work never starts inside a request, and why. */
const NO_REQUEST_CALLER: Record<string, string> = {
"lib/db-session.ts":
"the pool's connect hook pins the session time zone; it annotates nothing",
"lib/pwa/query-persister.ts":
"runs in the browser, where there is no wide event (and no async_hooks)",
"lib/jobs/reminder/climb-backfill-at-boot.ts":
"started once at worker boot, outside any request",
};
/** The argument text of every `trackBackgroundTask(...)` call in `code`. */
export function trackedHandles(code: string): string[] {
const out: string[] = [];
for (const m of code.matchAll(/\btrackBackgroundTask\s*\(/g)) {
let i = m.index + m[0].length;
let depth = 1;
while (depth > 0 && i < code.length) {
if (code[i] === "(") depth += 1;
else if (code[i] === ")") depth -= 1;
i += 1;
}
out.push(code.slice(m.index + m[0].length, i - 1));
}
return out;
}
const startsDetached = (handle: string) => /^\s*runDetached\s*\(/.test(handle);
describe("detached work cannot rename the request's event", () => {
const files = readSourceFolder("src", {
floor: 3000,
include: (rel) =>
!rel.startsWith("generated/") &&
!rel.startsWith("lib/logging/") &&
!rel.includes("__tests__/") &&
!/\.test\.tsx?$/.test(rel),
});
const calls = files.flatMap(([rel, text]) =>
trackedHandles(stripComments(text)).map((handle) => ({ rel, handle })),
);
it("finds the detached starts it polices", () => {
expect(calls.length).toBeGreaterThanOrEqual(8);
expect(calls.map((c) => c.rel)).toContain(
"lib/rollups/after-measurement-mutation.ts",
);
});
it("starts every request-path handle with runDetached", () => {
const offenders = calls
.filter((c) => !(c.rel in NO_REQUEST_CALLER))
.filter((c) => !startsDetached(c.handle))
.map((c) => `${c.rel}: ${c.handle.replace(/\s+/g, " ").slice(0, 80)}`);
expect(offenders).toEqual([]);
});
it("lists no file that stopped starting detached work", () => {
const callers = new Set(calls.map((c) => c.rel));
expect(
Object.keys(NO_REQUEST_CALLER).filter((rel) => !callers.has(rel)),
).toEqual([]);
});
it("the matcher reads a nested handle and tells the two shapes apart", () => {
const [plain, detached] = trackedHandles(
"trackBackgroundTask(work(a, b(c)).catch(() => {}));\n" +
"trackBackgroundTask(runDetached(() => work()).catch(() => {}));",
);
expect(plain).toBe("work(a, b(c)).catch(() => {})");
expect(startsDetached(plain)).toBe(false);
expect(startsDetached(detached)).toBe(true);
});
});
@@ -115,10 +115,17 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
// The medication create and edit write the schedule rows and answer
// through `medicationDetail` (read-medication.ts), which shapes them with
// scheduleWireFields and derives the due state the switch allows.
"lib/medications/create-medication.ts":
"writes the schedules; answers through medicationDetail",
"lib/medications/update-medication/update-medication.ts":
"writes the schedules; answers through medicationDetail",
// The medication edit: the schedule count and the primary schedule's id
// for the reminder-window bridge, and the replaced rows archived as an era
// whose payload is empty for a stretch with tracking off. Nothing due comes
// out of either; the edit answers through scheduleWireFields.
// out of either; the edit answers through medicationDetail.
"lib/medications/update-medication/preflight.ts":
"schedule count and the primary schedule's id, derives nothing due",
"lib/medications/update-medication/write.ts":
+1 -1
View File
@@ -10,7 +10,7 @@ import {
type ModelListing,
} from "@/lib/ai/model-availability";
import { decrypt } from "@/lib/crypto";
import { isAnthropicBaseUrl } from "@/lib/ai/provider";
import { isAnthropicBaseUrl } from "@/lib/ai/anthropic-host";
import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities";
import { loadAiUsageSettings } from "@/lib/ai/capabilities/usage";
import {
+5 -4
View File
@@ -74,10 +74,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
// is rebuilt from a backup. Fire-and-forget; audit failures must
// never bubble into a user-facing 500.
fireAndForget(
auditLog("consent.ai.grant", {
userId: user.id,
details: { kind, receiptId: receipt.id },
}),
() =>
auditLog("consent.ai.grant", {
userId: user.id,
details: { kind, receiptId: receipt.id },
}),
{ action: "consent.ai.audit" },
);
@@ -57,17 +57,12 @@ export const PATCH = apiHandler(
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.entry.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, entryId }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
auditLog("custom-metric.entry.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, entryId },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
+9 -14
View File
@@ -89,20 +89,15 @@ export const PATCH = apiHandler(
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.update.validation-failed",
details: JSON.stringify({
issues: auditIssues,
customMetricId: id,
}),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
auditLog("custom-metric.metric.update.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
customMetricId: id,
},
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -42,6 +42,7 @@ vi.mock("next/headers", () => ({
import { GET, POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -73,6 +74,7 @@ const ROW = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
+6 -11
View File
@@ -70,17 +70,12 @@ async function postCustomMetric(request: NextRequest) {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
auditLog("custom-metric.metric.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -109,7 +109,7 @@ describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a dashboard.chart-overlay.validation-failed audit row", async () => {
it("keeps the owner's own dashboard.chart-overlay refusal off the ledger", async () => {
const res = await PUT(
putReq({
chartKey: "not-a-chart",
@@ -122,11 +122,9 @@ describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("dashboard.chart-overlay.validation-failed");
// An owner's own refused request is not an audit event: `auditLog`
// keeps it on the wide event and files no row.
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("does not block the 422 when the audit-row write rejects", async () => {
@@ -15,6 +15,7 @@
* the new prefs for the supplied `chartKey`, and writes it back inside the
* same request.
*/
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, sanitiseZodIssues } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
@@ -72,17 +73,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed")
) {
const issues = sanitiseZodIssues(error.issues);
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.chart-overlay.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("dashboard.chart-overlay.validation-failed", {
userId: user.id,
details: { issues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
}
},
});
+8 -9
View File
@@ -62,6 +62,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,6 +79,7 @@ function req(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
vi.mocked(prisma.device.findFirst).mockResolvedValue(null);
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
@@ -406,19 +408,16 @@ describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => {
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("devices.register.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"devices.register.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
+6 -11
View File
@@ -124,17 +124,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("devices.register.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -66,6 +66,7 @@ import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { auditLog } from "@/lib/auth/audit";
function postReq(
body: unknown,
@@ -83,6 +84,7 @@ function postReq(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
@@ -145,18 +147,15 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
const res = await POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("ingest.medication.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"ingest.medication.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
});
+6 -11
View File
@@ -174,17 +174,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("ingest.medication.validation-failed", {
userId: apiToken.userId,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -44,6 +44,12 @@ vi.mock("@/lib/logging/context", async (importOriginal) => {
};
});
// The real `auditLog`, observed: it decides whether a refusal is filed.
vi.mock("@/lib/auth/audit", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/audit")>();
return { ...actual, auditLog: vi.fn(actual.auditLog) };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -57,6 +63,7 @@ import { GET, PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
@@ -442,31 +449,31 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
);
});
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
it("hands the insights.layout.validation-failed refusal to auditLog, which files no row for the owner", async () => {
const res = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("insights.layout.validation-failed");
const details = JSON.parse(call.data.details) as {
expect(auditLog).toHaveBeenCalledTimes(1);
const [action, opts] = vi.mocked(auditLog).mock.calls[0]!;
expect(action).toBe("insights.layout.validation-failed");
expect(opts?.userId).toBe("user-1");
// An owner's own refused request is not an audit event.
expect(prisma.auditLog.create).not.toHaveBeenCalled();
const details = opts?.details as {
issues: Array<{ path: string; code: string }>;
};
expect(details.issues.length).toBe(2);
for (const issue of details.issues) {
// The persisted row strips `message` so a future Zod code that
// The filed issues strip `message` so a future Zod code that
// echoes the offending value cannot leak into the audit
// surface.
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
it("dedups the audit-ledger write across two sequential 422s for the same user", async () => {
it("dedups the audit call across two sequential 422s for the same user", async () => {
const res1 = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res1.status).toBe(422);
const res2 = await callPut(makeReq({ version: 99, tiles: [] }));
@@ -474,7 +481,8 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.create).not.toHaveBeenCalled();
// The full multi-issue envelope still rides on every 422 — the
// dedup only suppresses the breadcrumb write, never the response.
+7 -11
View File
@@ -13,6 +13,7 @@
* single blob with a `surface` discriminator would force every read to
* demux first.
*/
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -178,17 +179,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "insights.layout.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
auditLog("insights.layout.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
}
return returnAllZodIssues(parsed.error, 422);
}
@@ -48,6 +48,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -56,6 +57,7 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: null,
@@ -220,18 +222,16 @@ describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", (
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("integrations.healthkit.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"integrations.healthkit.validation-failed",
expect.anything(),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
+6 -11
View File
@@ -268,17 +268,12 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("integrations.healthkit.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
+5 -10
View File
@@ -163,15 +163,10 @@ async function commitOcrRows(request: NextRequest) {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.ocr.commit.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {});
auditLog("labs.ocr.commit.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {});
},
});
if (!parsed.ok) return parsed.error;
@@ -402,7 +397,7 @@ async function commitOcrRows(request: NextRequest) {
// A lab panel just landed — resolve any "annual blood panel" reminders now
// rather than waiting on the cron. Fire-and-forget.
if (inserted.length > 0) {
fireAndForget(enqueueReminderSatisfy(user.id), {
fireAndForget(() => enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
+1 -1
View File
@@ -373,7 +373,7 @@ async function postLabResult(request: NextRequest) {
// v1.18.1 — eventful Lab↔Vorsorge satisfaction. A lab panel just
// landed; resolve the user's free-text "annual blood panel" reminders
// now rather than waiting on the 15-min cron. Fire-and-forget.
fireAndForget(enqueueReminderSatisfy(user.id), {
fireAndForget(() => enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
@@ -57,7 +57,8 @@ vi.mock("@/lib/logging/context", async (importOriginal) => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/logging/fire-and-forget", () => ({
fireAndForget: (promise: Promise<unknown>) => void promise.catch(() => {}),
fireAndForget: (start: () => Promise<unknown>) =>
void start().catch(() => {}),
}));
vi.mock("@/lib/crypto/note-cipher", () => ({
encryptNote: vi.fn(() => null),
@@ -41,6 +41,39 @@ vi.mock("@/lib/db", () => ({
},
}));
// The answer is the medication's read model (`medicationDetail`), whose
// reads and resolution the integration walks hold against the contract.
// Here it stands in with the part these tests read: the written row with
// its schedules wired as every medication answer wires them.
vi.mock("@/lib/medications/read-medication", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/medications/read-medication")>();
const { scheduleWireFields } =
await import("@/lib/medications/intake-tracking");
const { serializeScheduleUnitsPerDose } =
await import("@/lib/medications/schedule-units-dto");
return {
...actual,
medicationDetail: vi.fn(
async ({
medication,
}: {
medication: Parameters<typeof actual.medicationDetail>[0]["medication"];
}) => ({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
}),
),
};
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -34,6 +34,39 @@ vi.mock("@/lib/db", () => ({
},
}));
// The answer is the medication's read model (`medicationDetail`), whose
// reads and resolution the integration walks hold against the contract.
// Here it stands in with the part these tests read: the written row with
// its schedules wired as every medication answer wires them.
vi.mock("@/lib/medications/read-medication", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/medications/read-medication")>();
const { scheduleWireFields } =
await import("@/lib/medications/intake-tracking");
const { serializeScheduleUnitsPerDose } =
await import("@/lib/medications/schedule-units-dto");
return {
...actual,
medicationDetail: vi.fn(
async ({
medication,
}: {
medication: Parameters<typeof actual.medicationDetail>[0]["medication"];
}) => ({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
}),
),
};
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -88,6 +121,7 @@ vi.mock("next/headers", () => ({
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { medicationDetail } from "@/lib/medications/read-medication";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { getUserTodayBounds } from "@/lib/tz/local-day";
@@ -657,11 +691,18 @@ describe("POST /api/medications — Apple Health mirror (v1.28)", () => {
const res = await POST(postReq(MIRROR_BODY));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { id: string; externalId: string; category: string };
data: { id: string; externalId: string };
};
expect(body.data.id).toBe("med-existing");
expect(body.data.externalId).toBe("hk-concept-1");
expect(body.data.category).toBe("OTHER");
// Answered with the read model of the stored row, keeping the create's
// action on the event.
expect(medicationDetail).toHaveBeenCalledWith(
expect.objectContaining({
medication: expect.objectContaining({ id: "med-existing" }),
announce: false,
}),
);
// Idempotent replay: nothing written.
expect(prisma.medication.create).not.toHaveBeenCalled();
});
@@ -85,6 +85,7 @@ import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -109,6 +110,7 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
@@ -176,17 +178,15 @@ describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", (
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("medications.intake.bulk.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"medications.intake.bulk.validation-failed",
expect.anything(),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(
postReq({
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
+7 -12
View File
@@ -39,10 +39,10 @@
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
* §"Cumulative metrics" sibling section for the SyncMode rationale.
*/
import { auditLog } from "@/lib/auth/audit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -198,17 +198,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("medications.intake.bulk.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medications.intake.bulk.invalid",
});
@@ -243,7 +243,7 @@ describe("PUT /api/medications/layout — 422 multi-issue envelope", () => {
expect(res.status).toBe(422);
});
it("writes one deduped audit breadcrumb for repeated 422s", async () => {
it("files no row for an owner's repeated refusals", async () => {
const res1 = await callPut(makeReq({ version: 9 }));
const res2 = await callPut(makeReq({ version: 9 }));
expect(res1.status).toBe(422);
@@ -251,12 +251,9 @@ describe("PUT /api/medications/layout — 422 multi-issue envelope", () => {
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("medication.layout.validation-failed");
// An owner's own refused request is not an audit event: `auditLog`
// keeps it on the wide event and files no row.
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
+7 -11
View File
@@ -11,6 +11,7 @@
* lives on its own `User` column (`medication_list_layout_json`) per
* the per-surface-column convention.
*/
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -151,17 +152,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medication.layout.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
auditLog("medication.layout.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
}
return returnAllZodIssues(parsed.error, 422);
}
@@ -100,6 +100,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { createTagLinks } from "@/lib/mood/tag-links";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -139,6 +140,7 @@ function existingRow(overrides: {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
@@ -213,17 +215,15 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("mood.bulk.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"mood.bulk.validation-failed",
expect.anything(),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(
postReq({
entries: [
+7 -12
View File
@@ -24,10 +24,10 @@
* The route authenticates, meters, caps and parses; the per-entry writes live
* in `src/lib/mood/bulk-ingest.ts`.
*/
import { auditLog } from "@/lib/auth/audit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -206,17 +206,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood.bulk.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("mood.bulk.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.bulk.invalid",
});
+2 -1
View File
@@ -7,6 +7,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import type { RouteSample } from "@/lib/workouts/route-samples";
/**
* v1.4.32 — TanStack Query wrapper for `GET /api/workouts`.
@@ -325,7 +326,7 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
metadata: unknown;
route: {
geometry: unknown;
sampleTimestamps: string[] | null;
sampleTimestamps: RouteSample[] | null;
} | null;
/**
* Raw per-workout HR sample envelope. `samples.samples` is dropped
+18
View File
@@ -0,0 +1,18 @@
/**
* True when a base URL addresses Anthropic's API: the host itself or any
* subdomain of `anthropic.com`, case-insensitively. Parsed rather than
* substring-matched, so `https://anthropic.com.attacker.example/v1` and
* `https://evil.example/?x=api.anthropic.com` both answer false.
*
* An unparseable value answers false and keeps the OpenAI path, which is the
* behaviour that value had before this function existed.
*/
export function isAnthropicBaseUrl(baseUrl: string): boolean {
let host: string;
try {
host = new URL(baseUrl).hostname.toLowerCase();
} catch {
return false;
}
return host === "anthropic.com" || host.endsWith(".anthropic.com");
}
+41 -41
View File
@@ -3,7 +3,8 @@ import { readOperatorRow } from "@/lib/settings/operator-row";
import { bindResponseTimeout } from "./effective-timeout";
import { bindReasoningEffort } from "./reasoning-effort";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { openStoredKey } from "./stored-key";
import { isAnthropicBaseUrl } from "./anthropic-host";
import { CodexClient, resolveCodexVisionSlug } from "./codex-client";
import { OpenAIClient } from "./openai-client";
import { AnthropicClient } from "./anthropic-client";
@@ -400,8 +401,12 @@ function buildCompatProvider(row: {
}): AIProvider | null {
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
if (!row.aiCompatBaseUrl || !model) return null;
const apiKey = row.aiCompatKeyEncrypted
? openStoredKey(row.aiCompatKeyEncrypted)
: "";
if (apiKey === null) return null;
return new OpenAIClient({
apiKey: row.aiCompatKeyEncrypted ? decrypt(row.aiCompatKeyEncrypted) : "",
apiKey,
model,
baseUrl: row.aiCompatBaseUrl,
providerType: "openai-compatible",
@@ -424,17 +429,21 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
// configuration survived in the row, refuse to forward an Anthropic
// key to it. Anthropic has no per-tenant base URL the UI exposes;
// the SDK default is correct.
const apiKey = openStoredKey(row.aiAnthropicKeyEncrypted);
if (apiKey === null) return null;
return new AnthropicClient({
apiKey: decrypt(row.aiAnthropicKeyEncrypted),
apiKey,
model: row.aiModel ?? "claude-sonnet-4-6",
});
}
case "LOCAL": {
if (!row.aiBaseUrl) return null;
const apiKey = row.aiLocalKeyEncrypted
? openStoredKey(row.aiLocalKeyEncrypted)
: null;
if (row.aiLocalKeyEncrypted && apiKey === null) return null;
return new LocalOpenAICompatibleClient({
apiKey: row.aiLocalKeyEncrypted
? decrypt(row.aiLocalKeyEncrypted)
: null,
apiKey,
model: row.aiModel ?? "local-model",
baseUrl: row.aiBaseUrl,
operatorTrusted: isOperatorOwned(row),
@@ -454,9 +463,12 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
// Belt-and-braces: ignore any persisted `aiBaseUrl`. The column is
// shared with LOCAL, so a stale LAN URL there would otherwise
// redirect the user's OpenAI key to a private host.
if (!row.aiOpenaiKeyEncrypted) return null;
const apiKey = row.aiOpenaiKeyEncrypted
? openStoredKey(row.aiOpenaiKeyEncrypted)
: null;
if (apiKey === null) return null;
return new OpenAIClient({
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
apiKey,
model: row.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
@@ -469,25 +481,6 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
}
}
/**
* True when a base URL addresses Anthropic's API: the host itself or any
* subdomain of `anthropic.com`, case-insensitively. Parsed rather than
* substring-matched, so `https://anthropic.com.attacker.example/v1` and
* `https://evil.example/?x=api.anthropic.com` both answer false.
*
* An unparseable value answers false and keeps the OpenAI path, which is the
* behaviour that value had before this function existed.
*/
export function isAnthropicBaseUrl(baseUrl: string): boolean {
let host: string;
try {
host = new URL(baseUrl).hostname.toLowerCase();
} catch {
return false;
}
return host === "anthropic.com" || host.endsWith(".anthropic.com");
}
/**
* The operator's global provider — one key, one model, one base URL in the
* admin settings, serving every user who has configured nothing themselves.
@@ -515,17 +508,20 @@ async function resolveAdminProvider(): Promise<AIProvider> {
where: { id: "singleton" },
});
if (settings?.adminAiKeyEncrypted) {
const adminKey = settings?.adminAiKeyEncrypted
? openStoredKey(settings.adminAiKeyEncrypted)
: null;
if (settings && adminKey !== null) {
const baseUrl = settings.adminAiBaseUrl ?? "https://api.openai.com/v1";
if (isAnthropicBaseUrl(baseUrl)) {
return new AnthropicClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
apiKey: adminKey,
model: settings.adminAiModel ?? "claude-sonnet-4-6",
baseUrl,
});
}
return new OpenAIClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
apiKey: adminKey,
model: settings.adminAiModel ?? "gpt-4o",
baseUrl,
// The instance-wide provider is the operator's own configuration.
@@ -1351,27 +1347,31 @@ async function resolveProviderForType(
}
case "openai": {
const enc = ctx.userRow?.aiOpenaiKeyEncrypted;
if (!enc) return null;
const apiKey = enc ? openStoredKey(enc) : null;
if (apiKey === null) return null;
return new OpenAIClient({
apiKey: decrypt(enc),
apiKey,
model: ctx.userRow?.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
}
case "anthropic": {
const enc = ctx.userRow?.aiAnthropicKeyEncrypted;
if (!enc) return null;
const apiKey = enc ? openStoredKey(enc) : null;
if (apiKey === null) return null;
return new AnthropicClient({
apiKey: decrypt(enc),
apiKey,
model: ctx.userRow?.aiModel ?? "claude-sonnet-4-6",
});
}
case "local": {
if (!ctx.userRow?.aiBaseUrl) return null;
const apiKey = ctx.userRow.aiLocalKeyEncrypted
? openStoredKey(ctx.userRow.aiLocalKeyEncrypted)
: null;
if (ctx.userRow.aiLocalKeyEncrypted && apiKey === null) return null;
return new LocalOpenAICompatibleClient({
apiKey: ctx.userRow.aiLocalKeyEncrypted
? decrypt(ctx.userRow.aiLocalKeyEncrypted)
: null,
apiKey,
model: ctx.userRow.aiModel ?? "local-model",
baseUrl: ctx.userRow.aiBaseUrl,
operatorTrusted: isOperatorOwned(ctx.userRow),
@@ -1505,7 +1505,7 @@ async function resolveProviderForTestUnbound(
const apiKey =
override.anthropicKey?.trim() ||
(stored?.aiAnthropicKeyEncrypted
? decrypt(stored.aiAnthropicKeyEncrypted)
? (openStoredKey(stored.aiAnthropicKeyEncrypted) ?? "")
: "");
if (!apiKey) {
throw new AITestConfigError(422, "Anthropic API key not configured");
@@ -1534,7 +1534,7 @@ async function resolveProviderForTestUnbound(
const apiKey =
override.localKey?.trim() ||
(stored?.aiLocalKeyEncrypted
? decrypt(stored.aiLocalKeyEncrypted)
? (openStoredKey(stored.aiLocalKeyEncrypted) ?? null)
: null);
return new LocalOpenAICompatibleClient({
apiKey,
@@ -1583,7 +1583,7 @@ async function resolveProviderForTestUnbound(
const apiKey =
override.compatKey?.trim() ||
(stored?.aiCompatKeyEncrypted
? decrypt(stored.aiCompatKeyEncrypted)
? (openStoredKey(stored.aiCompatKeyEncrypted) ?? "")
: "");
return new OpenAIClient({
apiKey,
@@ -1608,7 +1608,7 @@ async function resolveProviderForTestUnbound(
const userKey =
override.openaiKey?.trim() ||
(stored?.aiOpenaiKeyEncrypted
? decrypt(stored.aiOpenaiKeyEncrypted)
? (openStoredKey(stored.aiOpenaiKeyEncrypted) ?? "")
: "");
if (userKey) {
return new OpenAIClient({
+2 -1
View File
@@ -9,7 +9,8 @@ import { readOperatorRow } from "@/lib/settings/operator-row";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
import { isAnthropicBaseUrl, probeProviderChain } from "@/lib/ai/provider";
import { isAnthropicBaseUrl } from "@/lib/ai/anthropic-host";
import { probeProviderChain } from "@/lib/ai/provider";
import { isOperatorFundedProvider } from "@/lib/ai/coach/budget";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
+21
View File
@@ -0,0 +1,21 @@
/**
* Opening a stored AI-provider key.
*/
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
/**
* A stored provider key, or null when it cannot be opened: a key id retired
* from `ENCRYPTION_KEYS`, a corrupt row. An unreadable key is an unconfigured
* provider, the same as no key at all. Its entry drops out of the chain, so a
* generation answers the no-provider refusal (422 `ai.provider.none`)
* instead of the decrypt's 500, and the operator sees the warning.
*/
export function openStoredKey(encrypted: string): string | null {
try {
return decrypt(encrypted);
} catch {
getEvent()?.addWarning("ai: a stored provider key could not be opened");
return null;
}
}
+10 -1
View File
@@ -75,7 +75,16 @@ export const insightRecommendationSchema = z.union([
}),
]);
export const insightResultSchema = z.object({
/**
* The fields every insight reply must carry. Loose on purpose: the briefing
* prompt also asks for `dailyBriefing`, `trendAnnotations` and
* `storyboardAnnotations`, each read downstream (the briefing card, the
* dashboard snapshot, the today read), and a strict object dropped exactly
* those from a reply that validated, while a reply that did not validate
* kept them. Validation checks these fields and leaves the rest of the reply
* as it came.
*/
export const insightResultSchema = z.looseObject({
insightType: z.string().optional(),
summary: z.string(),
classification: z.enum([
+13 -16
View File
@@ -36,6 +36,7 @@
*/
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { runDetached } from "@/lib/logging/detached";
import type { WorkoutsProjection } from "@/lib/workouts/list-read";
export interface ServerCacheOptions<T = unknown> {
@@ -406,23 +407,19 @@ export class ServerCache<T> {
// single-flight slot as `wrap`, so an invalidation that lands
// while it is in flight discards its (pre-write) result instead
// of re-caching it as fresh.
const refresh = this.startBuild(key, builder, ttlMsOverride).promise;
// Detach: the foreground caller does not await the rebuild.
// Handle the rejection on the detached handle so an
// unhandled-rejection warning never fires — but never silently:
// a refresh that keeps failing means the bucket serves an
// ever-older stale value until the SWR window finally lapses,
// so the failure must reach the logs. There is no request
// context here (the caller already returned), so a structured
// console.error stands in for the wide-event annotation; the
// key is logged as its djb2 hash, matching the cache-outcome
// meta convention, so the userId segment never reaches stdout.
// Registered so the test suite can await it: the error below is a
// console write on a detached handle, and one that lands while a
// vitest worker is closing its RPC channel fails the whole run.
// No-op outside test.
// Detached (`runDetached`): the caller does not await the rebuild,
// and the rebuild cannot rename the caller's event. Its rejection is
// handled, but never silently: a refresh that keeps failing serves
// an ever-older value until the SWR window lapses. It may outlive
// the event, so a structured console.error carries it, the key as
// its djb2 hash (the cache-outcome meta convention) so the userId
// segment never reaches stdout. Registered so the test suite can
// await it: a console write landing while a vitest worker closes
// its RPC channel fails the whole run. No-op outside test.
trackBackgroundTask(
refresh.catch((err) => {
runDetached(
() => this.startBuild(key, builder, ttlMsOverride).promise,
).catch((err) => {
console.error(
`cache.${this.opts.name ?? "unnamed"}.refresh_failed`,
JSON.stringify({
+1 -1
View File
@@ -79,7 +79,7 @@ export async function handleFitbitSync(
// reminders eventfully. Fire-and-forget; the cron is the net.
onUserSynced: (userId, imported) => {
if (imported > 0) {
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
}
@@ -48,7 +48,6 @@ vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
probeProviderPresence: vi.fn(async () => true),
probeProviderChain: vi.fn(),
isAnthropicBaseUrl: () => false,
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
@@ -0,0 +1,37 @@
/**
* A reply that fits the insight schema keeps the blocks the prompt asks for
* beside it. The strict object used to drop `dailyBriefing` from exactly the
* replies that validated.
*/
import { describe, expect, it } from "vitest";
import { parseBriefingReply } from "../grounding";
const VALID = {
summary: "Steady week.",
classification: "gut",
findings: [],
correlations: [],
recommendations: [],
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
disclaimer: "Consult your doctor.",
};
const BLOCKS = {
dailyBriefing: { paragraph: "Readings held steady.", signalsOfDay: [] },
trendAnnotations: [{ metric: "weight", note: "flat" }],
storyboardAnnotations: [],
};
describe("parseBriefingReply", () => {
it("keeps the briefing blocks of a reply that validates", () => {
const parsed = parseBriefingReply(JSON.stringify({ ...VALID, ...BLOCKS }));
expect(parsed).toMatchObject(BLOCKS);
expect(parsed).toMatchObject({ summary: "Steady week." });
});
it("keeps a reply that does not validate as it was parsed", () => {
const loose = { summary: "No classification.", ...BLOCKS };
expect(parseBriefingReply(JSON.stringify(loose))).toEqual(loose);
});
});
@@ -45,8 +45,8 @@ export function runBriefingCall(
// `getInsightsSystemPrompt` returned the old `{changed, stable, drivers,
// …}` shape and never asked the model for dailyBriefing, so the hero
// strip's "Re-run analysis" button kept producing a payload with no
// briefing block. The reply tolerates the strict shape via
// `insightResultSchema.safeParse`'s soft fallback to the parsed object.
// briefing block. `insightResultSchema` is loose, so a reply that
// validates keeps these blocks, and one that does not is kept as parsed.
systemPrompt: buildSystemPromptWithReferences(
ctx.locale,
prompt.referenceMetrics,
+2 -2
View File
@@ -342,8 +342,8 @@ async function rerollBriefingParagraph(args: {
/**
* Parse + (best-effort) validate a comprehensive provider reply. Returns the
* parsed payload (validated when it matches `insightResultSchema`, else the
* raw object so the richer optional blocks survive), or null when the reply
* parsed payload (validated when it matches `insightResultSchema`, which
* keeps the richer optional blocks, else the raw object), or null when the reply
* is not valid JSON even after fence-stripping. Null signals the caller to
* run its one corrective retry.
*/
+11 -8
View File
@@ -23,6 +23,7 @@ import type { PgBoss } from "pg-boss";
import { startupPhase } from "@/lib/boot/startup-state";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { runDetached } from "@/lib/logging/detached";
/** The global slot a producer (re)start is parked in while it runs. */
export const BOSS_START_KEY = "__healthlog_pgboss_start__" as const;
@@ -80,14 +81,16 @@ export async function enqueueWhenBossReady(
}
if (!bossStartPending()) return "skipped";
trackBackgroundTask(
whenBossReady(label).then(async (boss) => {
if (!boss) return;
try {
await send(boss);
} catch (err) {
console.warn(`[pg-boss] deferred ${label} enqueue failed:`, err);
}
}),
runDetached(() =>
whenBossReady(label).then(async (boss) => {
if (!boss) return;
try {
await send(boss);
} catch (err) {
console.warn(`[pg-boss] deferred ${label} enqueue failed:`, err);
}
}),
),
);
return "deferred";
}
+6 -3
View File
@@ -25,6 +25,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { runDetached } from "@/lib/logging/detached";
import {
recomputeUserRollups,
ROLLUP_FOLD_WINDOW_MS,
@@ -68,9 +69,11 @@ export async function afterBulkImport(
if (types.length > 0) {
// Registered so the test suite can await the detached handle.
trackBackgroundTask(
invalidateStatusInsightsForTypes(userId, types, {
source: "manual",
}).catch((err) => {
runDetached(() =>
invalidateStatusInsightsForTypes(userId, types, {
source: "manual",
}),
).catch((err) => {
console.warn(
`[${label}] status-insight invalidate failed for user ${userId}`,
err,
+2 -2
View File
@@ -105,7 +105,7 @@ export async function handleGoogleHealthSync(
// next hour's, which is not a failure to retry.
if (result.failed && shouldStop()) {
if (result.imported > 0) {
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
}
@@ -128,7 +128,7 @@ export async function handleGoogleHealthSync(
: "complete";
if (result.imported > 0) {
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
}
+1 -1
View File
@@ -225,7 +225,7 @@ export function makePollCohortHandler({
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
// reminders eventfully. Fire-and-forget; the cron is the net.
if (imported > 0) {
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
}
+1 -1
View File
@@ -131,7 +131,7 @@ export async function runWhoopResourceSync(
// sync tail the webhook / per-resource cron paths pass through —
// `syncUserWhoop` covers the manual + backfill paths itself.
invalidateUserMeasurements(userId);
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
}
+3 -3
View File
@@ -124,7 +124,7 @@ export async function handleWithingsFallbackSync(
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
// reminders eventfully. Fire-and-forget; the cron is the net.
if (result.imported > 0) {
fireAndForget(enqueueReminderSatisfy(connection.userId), {
fireAndForget(() => enqueueReminderSatisfy(connection.userId), {
action: "reminder.satisfy.enqueue",
});
}
@@ -304,7 +304,7 @@ export async function handleWithingsActivitySync(
imported,
});
if (imported > 0) {
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
}
@@ -377,7 +377,7 @@ export async function handleWithingsSleepSync(
imported,
});
if (imported > 0) {
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
}
@@ -0,0 +1,82 @@
/**
* Detached work keeps its hands off the action of the event it was started
* from, and keeps everything else it may do there.
*/
import { describe, expect, it } from "vitest";
import { withBackgroundEvent } from "../background";
import { settleBackgroundTasks } from "../background-tasks";
import { annotate, eventStorage, getEvent } from "../context";
import { runDetached } from "../detached";
import { WideEventBuilder } from "../event-builder";
import { fireAndForget } from "../fire-and-forget";
describe("runDetached", () => {
it("drops the detached work's action for the parent event and keeps its meta", async () => {
const event = new WideEventBuilder();
await eventStorage.run(event, async () => {
annotate({ action: { name: "measurement.create" } });
await runDetached(async () => {
annotate({
action: { name: "insights.status.invalidate.skipped" },
meta: { reason: "no_provider" },
});
// The request's own event is still the one it reads.
expect(getEvent()).toBe(event);
});
expect(event.toJSON().action?.name).toBe("measurement.create");
// Back in the request, the request names its action as before.
annotate({ action: { name: "measurement.create.done" } });
});
expect(event.toJSON().action?.name).toBe("measurement.create.done");
expect(event.toJSON().meta?.reason).toBe("no_provider");
});
it("leaves an event the detached work opens for itself its own action", async () => {
const event = new WideEventBuilder();
let inner: string | undefined;
await eventStorage.run(event, async () => {
annotate({ action: { name: "measurement.create" } });
await runDetached(() =>
withBackgroundEvent("test.inner", async (evt) => {
annotate({ action: { name: "test.inner.ran" } });
inner = evt.toJSON().action?.name;
}),
);
});
expect(inner).toBe("test.inner.ran");
expect(event.toJSON().action?.name).toBe("measurement.create");
});
it("starts the work synchronously and turns a synchronous throw into a rejection", async () => {
let started = false;
const event = new WideEventBuilder();
await eventStorage.run(event, async () => {
const pending = runDetached(async () => {
started = true;
});
expect(started).toBe(true);
await pending;
await expect(
runDetached(() => {
throw new Error("sync");
}),
).rejects.toThrow("sync");
});
});
it("detaches what fireAndForget starts", async () => {
const event = new WideEventBuilder();
await eventStorage.run(event, async () => {
annotate({ action: { name: "labs.create" } });
fireAndForget(
async () => {
annotate({ action: { name: "reminder.satisfy.enqueue" } });
},
{ action: "reminder.satisfy.enqueue" },
);
await settleBackgroundTasks();
});
expect(event.toJSON().action?.name).toBe("labs.create");
});
});
@@ -12,7 +12,7 @@ afterEach(() => {
describe("fireAndForget", () => {
it("does not throw and does not reject when the promise resolves", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
fireAndForget(Promise.resolve("ok"), { action: "test.noop.run" });
fireAndForget(() => Promise.resolve("ok"), { action: "test.noop.run" });
await flush();
expect(warn).not.toHaveBeenCalled();
});
@@ -22,7 +22,7 @@ describe("fireAndForget", () => {
const builder = new WideEventBuilder();
await eventStorage.run(builder, async () => {
fireAndForget(Promise.reject(new Error("boom")), {
fireAndForget(() => Promise.reject(new Error("boom")), {
action: "reminder.satisfy.enqueue",
meta: { userScope: "u_1" },
});
@@ -39,7 +39,7 @@ describe("fireAndForget", () => {
it("warns with the action label only — never the error payload", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
fireAndForget(Promise.reject(new Error("secret-token-xyz")), {
fireAndForget(() => Promise.reject(new Error("secret-token-xyz")), {
action: "integration.reauth.mark",
});
await flush();
@@ -52,7 +52,7 @@ describe("fireAndForget", () => {
it("is a safe no-op outside a request context", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => {
fireAndForget(Promise.reject(new Error("no context")), {
fireAndForget(() => Promise.reject(new Error("no context")), {
action: "test.orphan.run",
});
}).not.toThrow();
@@ -63,7 +63,7 @@ describe("fireAndForget", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const builder = new WideEventBuilder();
await eventStorage.run(builder, async () => {
fireAndForget(Promise.reject("plain-string"), {
fireAndForget(() => Promise.reject("plain-string"), {
action: "test.reject.string",
});
await flush();
+9 -1
View File
@@ -1,6 +1,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { WideEvent } from "./types";
import { WideEventBuilder } from "./event-builder";
import { detachedParent, registerDetachedEventSource } from "./detached";
import { registerSourcePriorityParseObserver } from "@/lib/validations/source-priority";
import { registerRecurrenceParseErrorObserver } from "@/lib/medications/scheduling/recurrence";
import {
@@ -16,6 +17,10 @@ export function getEvent(): WideEventBuilder | null {
return eventStorage.getStore() ?? null;
}
// `runDetached` (./detached) reads the current event through this getter;
// registered here so the two modules do not import each other.
registerDetachedEventSource(getEvent);
/**
* Convenience: Felder im aktuellen Event annotieren.
* Safe to call anywhere — no-op wenn kein Kontext aktiv.
@@ -26,7 +31,10 @@ export function annotate(fields: {
}): void {
const evt = getEvent();
if (!evt) return;
if (fields.action) evt.setAction(fields.action);
// Detached work never renames the event it was started from.
if (fields.action && detachedParent() !== evt) {
evt.setAction(fields.action);
}
if (fields.meta) {
for (const [key, value] of Object.entries(fields.meta)) {
evt.addMeta(key, value);
+47
View File
@@ -0,0 +1,47 @@
/**
* Start work the caller does not wait for (a status re-warm, a reminder
* enqueue, a stale-cache rebuild) so it cannot rename the event it was
* started from.
*
* Detached work still runs inside the request's context: it reads the
* request's auth for its audit rows and adds meta, warnings and external
* calls to the request's event while that event is open. What it must not do
* is set the event's action. Its `annotate({ action })` used to land on the
* request's event whenever it ran before the event was emitted, so the same
* write was logged as `measurement.create` on one request and as the
* re-warm's `insights.status.invalidate.skipped` on the next. Under
* `runDetached` an action from the detached work is dropped for the parent
* event (`annotate` in ./context checks `detachedParent`); an event the work
* opens for itself (`withBackgroundEvent`) is its own and keeps its action.
*
* Kept apart from ./context, which most unit tests replace wholesale: a test
* that mocks the context still gets a working `runDetached`, which then
* simply starts the work.
*/
import { AsyncLocalStorage } from "node:async_hooks";
/** The event a piece of detached work was started from. */
const detachedFrom = new AsyncLocalStorage<object>();
let currentEvent: () => object | null = () => null;
/** Called once by ./context with its event getter. */
export function registerDetachedEventSource(source: () => object | null): void {
currentEvent = source;
}
/** The event the running work was detached from, if any. */
export function detachedParent(): object | undefined {
return detachedFrom.getStore();
}
/** Start `start` detached from the current event; it runs synchronously. */
export function runDetached<T>(start: () => Promise<T>): Promise<T> {
const parent = currentEvent();
if (!parent) return start();
try {
return detachedFrom.run(parent, start);
} catch (error) {
return Promise.reject(error);
}
}
+10 -6
View File
@@ -1,4 +1,5 @@
import { annotate } from "./context";
import { runDetached } from "./detached";
import { trackBackgroundTask } from "./background-tasks";
/**
@@ -22,23 +23,26 @@ import { trackBackgroundTask } from "./background-tasks";
* Only the caller-supplied action label — never the error text — reaches
* the raw console sink, keeping it redaction-safe by construction.
*
* The promise stays fire-and-forget: the caller does not await it and its
* resolution is discarded exactly as before. Under test the settled handle is
* registered with `trackBackgroundTask` so the suite setup can await it — see
* The work stays fire-and-forget: the caller does not await it and its
* resolution is discarded exactly as before. It is started through
* `runDetached`, so nothing it annotates can rename the caller's event; that
* is why it is passed as a function rather than as a promise already
* running. Under test the settled handle is registered with
* `trackBackgroundTask` so the suite setup can await it — see
* `./background-tasks` for why a late `console.warn` is otherwise a teardown
* hazard.
*
* @param promise The background work. May already be running.
* @param start Starts the background work.
* @param context.action A stable `<surface>.<noun>.<verb>` label (never user
* input) identifying which background task failed.
* @param context.meta Optional extra fields to attach to the breadcrumb; they
* ride the annotate/redact path with the error message.
*/
export function fireAndForget(
promise: Promise<unknown>,
start: () => Promise<unknown>,
context: { action: string; meta?: Record<string, unknown> },
): void {
const settled = Promise.resolve(promise).catch((error: unknown) => {
const settled = runDetached(start).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
annotate({
meta: {
+1 -1
View File
@@ -201,7 +201,7 @@ export async function enqueueRecordJobs(args: {
// Eventful Vorsorge satisfaction. A matching reading just landed; resolve
// the user's reminders now rather than waiting on the 15-min cron.
// Fire-and-forget — the cron reconciles on enqueue miss.
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
try {
+13 -12
View File
@@ -208,7 +208,7 @@ async function afterReadingsCreated(
// Eventful Vorsorge satisfaction. Resolve the user's reminders against the
// just-landed readings now. Fire-and-forget.
fireAndForget(enqueueReminderSatisfy(user.id), {
fireAndForget(() => enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
@@ -232,17 +232,18 @@ async function afterReadingsCreated(
// symptom-coupled emergency copy. Fire-and-forget — a notification failure
// never fails the write.
fireAndForget(
runSafetyFloorCheck({
userId: user.id,
written: rows.map((r) => ({
type: r.type,
value: r.value,
measuredAt: r.measuredAt,
glucoseContext: r.glucoseContext,
})),
symptomsPresent: options.symptomsPresent,
timezone: user.timezone ?? undefined,
}),
() =>
runSafetyFloorCheck({
userId: user.id,
written: rows.map((r) => ({
type: r.type,
value: r.value,
measuredAt: r.measuredAt,
glucoseContext: r.glucoseContext,
})),
symptomsPresent: options.symptomsPresent,
timezone: user.timezone ?? undefined,
}),
{ action: "measurement.safety_floor.check" },
);
@@ -147,7 +147,7 @@ export async function createNamedSleepStage(
},
});
invalidateUserMeasurements(userId, { evict: true });
fireAndForget(enqueueReminderSatisfy(userId), {
fireAndForget(() => enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
if (status === "inserted") {
+36 -95
View File
@@ -10,33 +10,27 @@
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import type { SessionUser } from "@/lib/auth/session-user";
import {
courseRefusalResponse,
resolveCourseFields,
} from "@/lib/medications/courses";
import { courseRefusalResponse } from "@/lib/medications/courses";
import {
dateOfDayKey,
dayKeyOfDate,
validateCourses,
} from "@/lib/medications/course-window";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import {
isCustomMedicationCategoryKey,
type createMedicationSchema,
} from "@/lib/validations/medication";
import type { createMedicationSchema } from "@/lib/validations/medication";
import {
isAssignableCategory,
resolveMedicationCategories,
setMedicationCategory,
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
import type { Medication, MedicationSchedule } from "@/generated/prisma/client";
import {
medicationDetail,
type ReadUser,
} from "@/lib/medications/read-medication";
export type CreateMedicationInput = z.output<typeof createMedicationSchema>;
type ScheduleInput = NonNullable<CreateMedicationInput["schedules"]>[number];
@@ -67,28 +61,14 @@ export type CreateMedicationOutcome =
* EXISTING row (status 200, vs 201 for a fresh create) when the caller
* re-posts a `(externalSource, externalId)` pair this user already mirrors —
* the iOS re-sync replays its full HealthKit medication list, and a replay
* must neither duplicate nor 500. Response shape mirrors the fresh-create
* envelope (schedules included, category joined, Decimal unwrapped); no
* audit row and no cache eviction because nothing was written.
* must neither duplicate nor 500. It answers the medication's read model,
* the same body the fresh create and the detail read answer; no audit row
* and no cache eviction because nothing was written.
*/
async function existingMirrorBody(
medication: Record<string, unknown> & {
id: string;
unitsPerDose: unknown;
trackIntake: boolean;
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
},
user: ReadUser,
medication: Medication & { schedules: MedicationSchedule[] },
): Promise<CreateMedicationOutcome> {
let category = "OTHER";
let categoryLabel: string | null = null;
try {
const categories = await resolveMedicationCategories([medication.id]);
category = categories[medication.id]?.category ?? "OTHER";
categoryLabel = categories[medication.id]?.categoryLabel ?? null;
} catch {
getEvent()?.addWarning("Medication categories could not be loaded");
}
annotate({
action: {
name: "medication.create",
@@ -97,25 +77,14 @@ async function existingMirrorBody(
},
meta: { idempotent_replay: true },
});
return {
kind: "existing",
body: {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
// (#1033) — record-only medications ship `schedules: []`.
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
Number(medication.unitsPerDose),
),
),
category,
categoryLabel,
},
};
// The read model, exactly as `GET /api/medications/{id}` answers it.
const body = await medicationDetail({
user,
grantId: null,
medication,
announce: false,
});
return { kind: "existing", body };
}
/**
@@ -127,16 +96,17 @@ async function existingMirrorBody(
* create covers the concurrent race. Null when the create goes ahead.
*/
async function existingMirrorOrCap(
userId: string,
user: ReadUser,
externalSource: NonNullable<CreateMedicationInput["externalSource"]>,
externalId: string,
): Promise<CreateMedicationOutcome | null> {
const userId = user.id;
const existing = await prisma.medication.findFirst({
where: { userId, externalSource, externalId },
include: { schedules: true },
});
if (existing) {
return existingMirrorBody(existing);
return existingMirrorBody(user, existing);
}
// Growth guard. This point is only reached when the idempotency lookup
@@ -438,7 +408,7 @@ function insertMedication(
}
export async function createMedication(input: {
user: Pick<SessionUser, "id" | "timezone">;
user: ReadUser;
data: CreateMedicationInput;
ipAddress: string | null;
}): Promise<CreateMedicationOutcome> {
@@ -468,11 +438,7 @@ export async function createMedication(input: {
}
if (externalSource !== undefined && externalId !== undefined) {
const mirror = await existingMirrorOrCap(
user.id,
externalSource,
externalId,
);
const mirror = await existingMirrorOrCap(user, externalSource, externalId);
if (mirror) return mirror;
}
@@ -516,19 +482,12 @@ export async function createMedication(input: {
where: { userId: user.id, externalSource, externalId },
include: { schedules: true },
});
if (winner) return existingMirrorBody(winner);
if (winner) return existingMirrorBody(user, winner);
}
throw err;
}
const normalizedCategory = await setMedicationCategory(
medication.id,
category,
);
const categoryLabel = isCustomMedicationCategoryKey(normalizedCategory)
? ((await resolveMedicationCategories([medication.id]))[medication.id]
?.categoryLabel ?? null)
: null;
await setMedicationCategory(medication.id, category);
await auditLog("medication.create", {
userId: user.id,
@@ -554,30 +513,12 @@ export async function createMedication(input: {
// read reflects the new schedule.
invalidateUserMedications(user.id, { evict: true });
return {
kind: "created",
body: {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
// (#1033) — record-only medications ship `schedules: []`.
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
category: normalizedCategory,
categoryLabel,
// (#1024) — the courses, as every medication read carries them.
...(
await resolveCourseFields(
[medication],
new Date(),
user.timezone || DEFAULT_TIMEZONE,
)
).get(medication.id),
},
};
// The read model, exactly as `GET /api/medications/{id}` answers it.
const body = await medicationDetail({
user,
grantId: null,
medication,
announce: false,
});
return { kind: "created", body };
}
+30 -8
View File
@@ -36,7 +36,7 @@ import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { buildRelated } from "@/lib/links/related";
type ReadUser = Pick<SessionUser, "id" | "timezone" | "locale">;
export type ReadUser = Pick<SessionUser, "id" | "timezone" | "locale">;
type MedicationWithSchedules = Medication & {
schedules: MedicationSchedule[];
};
@@ -195,6 +195,26 @@ export async function readMedication(input: {
if (!medication || medication.userId !== user.id) {
return null;
}
return medicationDetail({ user, grantId, medication, announce: true });
}
/**
* The read model of a medication row in hand: the detail answer, and the
* answer of a create or an edit, which pass the row they just wrote so they
* answer exactly what `GET /api/medications/{id}` would.
*/
export async function medicationDetail(input: {
user: ReadUser;
grantId: string | null;
medication: MedicationWithSchedules;
/**
* Name the request's action `medication.get`. A write answering with the
* read model passes false and keeps its own action.
*/
announce: boolean;
}): Promise<Record<string, unknown>> {
const { user, grantId, medication } = input;
const id = medication.id;
let category = "OTHER";
let categoryLabel: string | null = null;
@@ -208,13 +228,15 @@ export async function readMedication(input: {
const { display, userTz, now } = await loadDisplayDue(user, id, medication);
annotate({
action: {
name: "medication.get",
entity_type: "medication",
entity_id: id,
},
});
if (input.announce) {
annotate({
action: {
name: "medication.get",
entity_type: "medication",
entity_id: id,
},
});
}
const { schedulesDto, stockUnitsRemaining, stockDosesRemaining, runwayDays } =
await loadStock(user, id, medication);
@@ -18,14 +18,27 @@ import { applyCanonicalSlotWrite, type SlotIntakeRow } from "../slot-upsert";
const SLOT = new Date("2026-06-15T05:00:00.000Z");
function row(overrides: Partial<SlotIntakeRow> = {}): SlotIntakeRow {
const createdAt = new Date("2026-06-15T00:00:00.000Z");
return {
id: "row-1",
userId: "user-1",
medicationId: "med-1",
takenAt: null,
skipped: false,
autoMissed: false,
autoRecorded: false,
attributionSource: "AUTO",
idempotencyKey: null,
scheduledFor: SLOT,
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00.000Z"),
createdAt,
injectionSite: null,
doseTaken: null,
inventoryConsumption: null,
externalId: null,
updatedAt: createdAt,
syncVersion: 0,
deletedAt: null,
...overrides,
};
}
+10 -24
View File
@@ -14,7 +14,10 @@
* (PRN / off-slot beyond tolerance / cyclic off-week / no schedules) —
* the caller's signal to keep the unmodified insert behaviour.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import type {
MedicationIntakeEvent,
PrismaClient,
} from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
@@ -30,16 +33,12 @@ import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
type PrismaLike = Pick<PrismaClient, "medication" | "medicationIntakeEvent">;
/** The minimal intake-row shape the slot upsert reads + returns. */
export interface SlotIntakeRow {
id: string;
takenAt: Date | null;
skipped: boolean;
idempotencyKey: string | null;
scheduledFor: Date;
source: string;
createdAt: Date;
}
/**
* The intake row the slot upsert reads and returns: the whole row, so a
* write that converged onto a slot answers the same event shape as one that
* inserted its own (the declared `MedicationIntakeEvent`).
*/
export type SlotIntakeRow = MedicationIntakeEvent;
export interface ApplyCanonicalSlotWriteInput {
client: PrismaLike;
@@ -151,16 +150,6 @@ export interface ApplyCanonicalSlotWriteResult {
outcomeChangeRefused: boolean;
}
const SLOT_ROW_SELECT = {
id: true,
takenAt: true,
skipped: true,
idempotencyKey: true,
scheduledFor: true,
source: true,
createdAt: true,
} as const;
/**
* Forward grace a TAKEN write may still snap onto a slot whose anchor sits
* ahead of the take — the same clock-skew / early-dose margin the resolver's
@@ -239,7 +228,6 @@ async function findSlotRows(
scheduledFor: canonicalSlot,
deletedAt: null,
},
select: SLOT_ROW_SELECT,
// H1 — deterministic order; actioned-preference applied in
// `pickSlotRow`.
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
@@ -311,7 +299,6 @@ export async function applyCanonicalSlotWrite(
// v1.28 — external dose-event id only when the write carries one.
...(externalId !== null && { externalId }),
},
select: SLOT_ROW_SELECT,
})) as SlotIntakeRow;
return {
row: created,
@@ -453,7 +440,6 @@ async function applyToExisting(
// never clears a previously-recorded id.
...(externalId !== null && { externalId }),
},
select: SLOT_ROW_SELECT,
})) as SlotIntakeRow;
return {
@@ -7,6 +7,10 @@
* everything after it run here, in the order they always ran. The outcome is
* a refusal the route answers with `apiError`, or the edited medication.
*/
import {
medicationDetail,
type ReadUser,
} from "@/lib/medications/read-medication";
import { prisma } from "@/lib/db";
import {
enqueueSupplementNutrientRecompute,
@@ -14,7 +18,6 @@ import {
} from "@/lib/medications/ingredients/recompute-job";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import type { SessionUser } from "@/lib/auth/session-user";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
@@ -25,11 +28,8 @@ import {
import {
CourseWriteError,
courseRefusalResponse,
resolveCourseFields,
} from "@/lib/medications/courses";
import { refreshDoseDay } from "@/lib/medications/dose-day-refresh";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
applyRecordOnlyRule,
@@ -49,7 +49,7 @@ import {
type MedicationEditWrite,
} from "./write";
type EditUser = Pick<SessionUser, "id" | "timezone">;
type EditUser = ReadUser;
type EditedMedication = Awaited<ReturnType<typeof writeMedicationEdit>>;
export type MedicationEditOutcome =
@@ -238,7 +238,6 @@ async function writeCategory(
}
if (categoryToWrite !== undefined)
await setMedicationCategory(id, categoryToWrite);
return (await resolveMedicationCategories([id]))[id];
}
/** The audit row: C4 on the medication row itself, C7 on the schedule. */
@@ -336,12 +335,7 @@ export async function updateMedication(input: {
plan.primaryScheduleGracePatch,
);
const resolvedCategory = await writeCategory(
id,
write.input.category,
input.authMethod,
);
const normalizedCategory = resolvedCategory?.category ?? "OTHER";
await writeCategory(id, write.input.category, input.authMethod);
// A changed units-per-dose (on the medication or on a slot) changes what
// every taken dose added, so a medication with a composition has its doses
@@ -364,24 +358,12 @@ export async function updateMedication(input: {
// read reflects the schedule change.
invalidateUserMedications(user.id, { evict: true });
return {
ok: true,
body: {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
category: normalizedCategory,
categoryLabel: resolvedCategory?.categoryLabel ?? null,
// The courses, as every medication read carries them (#1024).
...(
await resolveCourseFields([medication], new Date(), write.courseTz)
).get(id),
},
};
// The read model, exactly as `GET /api/medications/{id}` answers it.
const body = await medicationDetail({
user,
grantId: null,
medication,
announce: false,
});
return { ok: true, body };
}
+15 -6
View File
@@ -255,6 +255,13 @@ const medicationIntakeImportJobStatusEnvelope = dataEnvelope(
"MedicationIntakeImportJobStatusEnvelope",
);
// Shared by the create's 201 and its mirror-replay 200: the envelope id is
// registered once.
const createMedicationResponse = dataEnvelope(
medicationDetailEntry,
"CreateMedicationResponse",
);
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/medications/intake": {
get: {
@@ -969,12 +976,14 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"201": {
description: "Created medication with its schedules.",
content: {
"application/json": {
schema: dataEnvelope(
medicationDetailEntry,
"CreateMedicationResponse",
),
},
"application/json": { schema: createMedicationResponse },
},
},
"200": {
description:
"A mirror create re-posting an `(externalSource, externalId)` pair the caller already holds: the existing medication, nothing written.",
content: {
"application/json": { schema: createMedicationResponse },
},
},
...stdResponses,
+61 -35
View File
@@ -61,6 +61,8 @@ export function recordActivityVerbLine(
action: string,
name: string,
): string {
const refused = refusedAttemptVerb(t, action, name);
if (refused !== null) return refused;
switch (action) {
case "measurement.create":
return t("recordSharing.activityVerb.measurementCreate", { name });
@@ -342,6 +344,54 @@ export function recordActivityVerbLine(
case "health-record.export":
return t("recordSharing.activityVerb.healthRecordExport", { name });
/* ---------------------------------------------------------------- */
/* Acts a delegable module performs outside its own route file. */
/* */
/* The two below are filed by library modules the routes import — a */
/* background worker's completion row and a lifecycle reconciliation */
/* — and a guard that walked only `route.ts` could not see either. */
/* Both carry the actor through, so both reach the owner's feed. */
/* ---------------------------------------------------------------- */
case "medication.intake.import":
return t("recordSharing.activityVerb.medicationIntakeImportFinished", {
name,
});
case "medication.oneShot.reconciled":
return t("recordSharing.activityVerb.medicationOneShotReconciled", {
name,
});
case "anamnesis.fact.create":
return t("recordSharing.activityVerb.anamnesisFactCreate", { name });
case "customMetric.create":
return t("recordSharing.activityVerb.customMetricCreate", { name });
case "customMetric.update":
return t("recordSharing.activityVerb.customMetricUpdate", { name });
case "customMetric.delete":
return t("recordSharing.activityVerb.customMetricDelete", { name });
case "documents.inbound.store":
return t("recordSharing.activityVerb.documentStore", { name });
case "documents.inbound.update":
return t("recordSharing.activityVerb.documentUpdate", { name });
case "documents.inbound.discard":
return t("recordSharing.activityVerb.documentDiscard", { name });
default:
return t("recordSharing.activity.acted", { name });
}
}
/**
* The sentence for an attempt that was refused, or null when `action` is no
* refusal. Asked first by `recordActivityVerbLine`.
*/
function refusedAttemptVerb(
t: Translator,
action: string,
name: string,
): string | null {
switch (action) {
/* ---------------------------------------------------------------- */
/* Attempts that were REFUSED, and things that happened around them. */
/* */
@@ -407,47 +457,23 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.rejectedDoseImport", { name });
case "custom-metric.entry.create.validation-failed":
return t("recordSharing.activityVerb.rejectedTrackedValue", { name });
case "custom-metric.metric.create.validation-failed":
return t("recordSharing.activityVerb.rejectedTrackedValueTypeCreate", {
name,
});
case "custom-metric.metric.update.validation-failed":
return t("recordSharing.activityVerb.rejectedTrackedValueTypeUpdate", {
name,
});
case "medication.layout.validation-failed":
return t("recordSharing.activityVerb.rejectedMedicationLayout", { name });
// Not a validation failure: the request was well-formed and admitted,
// and the queue would not take it. The owner sees an import that never
// ran rather than one that was refused, because those need different
// next steps.
case "medication.intake.import.kickoff.denied":
return t("recordSharing.activityVerb.deniedDoseImport", { name });
/* ---------------------------------------------------------------- */
/* Acts a delegable module performs outside its own route file. */
/* */
/* The two below are filed by library modules the routes import — a */
/* background worker's completion row and a lifecycle reconciliation */
/* — and a guard that walked only `route.ts` could not see either. */
/* Both carry the actor through, so both reach the owner's feed. */
/* ---------------------------------------------------------------- */
case "medication.intake.import":
return t("recordSharing.activityVerb.medicationIntakeImportFinished", {
name,
});
case "medication.oneShot.reconciled":
return t("recordSharing.activityVerb.medicationOneShotReconciled", {
name,
});
case "anamnesis.fact.create":
return t("recordSharing.activityVerb.anamnesisFactCreate", { name });
case "customMetric.create":
return t("recordSharing.activityVerb.customMetricCreate", { name });
case "customMetric.update":
return t("recordSharing.activityVerb.customMetricUpdate", { name });
case "customMetric.delete":
return t("recordSharing.activityVerb.customMetricDelete", { name });
case "documents.inbound.store":
return t("recordSharing.activityVerb.documentStore", { name });
case "documents.inbound.update":
return t("recordSharing.activityVerb.documentUpdate", { name });
case "documents.inbound.discard":
return t("recordSharing.activityVerb.documentDiscard", { name });
default:
return t("recordSharing.activity.acted", { name });
return null;
}
}
@@ -41,6 +41,9 @@ vi.mock("@/lib/insights/status-invalidation", () => ({
}));
import { afterMeasurementMutation } from "../after-measurement-mutation";
import { annotate, eventStorage } from "@/lib/logging/context";
import { WideEventBuilder } from "@/lib/logging/event-builder";
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
import type { MeasurementType } from "@/generated/prisma/client";
beforeEach(() => {
@@ -163,4 +166,35 @@ describe("afterMeasurementMutation", () => {
warn.mockRestore();
}
});
it("keeps the request's action name while the detached re-warm annotates its own", async () => {
// The re-warm is detached from the request but starts inside its
// context. It annotates an action of its own when it skips; that must
// never rename the write the request is reporting.
mocks.invalidate.mockImplementation(async () => {
annotate({
action: { name: "insights.status.invalidate.skipped" },
meta: { reason: "no_provider" },
});
});
const event = new WideEventBuilder();
await eventStorage.run(event, async () => {
annotate({ action: { name: "measurement.create" } });
await afterMeasurementMutation(
"user-1",
[
{
type: "WEIGHT" as MeasurementType,
measuredAt: day("2026-05-10T08:00:00Z"),
},
],
{ source: "manual" },
);
await settleBackgroundTasks();
});
expect(mocks.invalidate).toHaveBeenCalledTimes(1);
expect(event.toJSON().action?.name).toBe("measurement.create");
// What the detached work says still reaches the request's event.
expect(event.toJSON().meta?.reason).toBe("no_provider");
});
});
@@ -37,6 +37,7 @@ import {
type StatusIngestSource,
} from "@/lib/insights/status-invalidation";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { runDetached } from "@/lib/logging/detached";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { earliestMeasuredAt } from "@/lib/cache/closed-block";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -83,9 +84,11 @@ export async function afterMeasurementMutation(
// write on a detached handle, and one that lands while a vitest worker is
// closing its RPC channel fails the whole run. No-op outside test.
trackBackgroundTask(
invalidateStatusInsightsForTypes(userId, types, {
source: options.source,
}).catch((err) => {
runDetached(() =>
invalidateStatusInsightsForTypes(userId, types, {
source: options.source,
}),
).catch((err) => {
console.warn(`[${label}] status-insight invalidate failed`, err);
}),
);
+21 -18
View File
@@ -49,6 +49,7 @@ import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
import { annotate } from "@/lib/logging/context";
import { runDetached } from "@/lib/logging/detached";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import {
@@ -318,25 +319,27 @@ export async function recomputeMoodBucketsForEntry(
// write on a detached handle, and one that lands while a vitest worker is
// closing its RPC channel fails the whole run. No-op outside test.
trackBackgroundTask(
Promise.all(
ASYNC_GRANULARITIES.map((granularity) => {
const { from, to } = bucketSpan(dateLabel, granularity);
return enqueueMoodRollupRecompute({
userId,
granularity,
from,
to,
}).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
mood_rollup_enqueue_failed: true,
mood_rollup_enqueue_error: message,
},
runDetached(() =>
Promise.all(
ASYNC_GRANULARITIES.map((granularity) => {
const { from, to } = bucketSpan(dateLabel, granularity);
return enqueueMoodRollupRecompute({
userId,
granularity,
from,
to,
}).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
mood_rollup_enqueue_failed: true,
mood_rollup_enqueue_error: message,
},
});
console.error("[mood-rollups] async enqueue failed:", message);
});
console.error("[mood-rollups] async enqueue failed:", message);
});
}),
}),
),
),
);
}
@@ -150,6 +150,23 @@ describe("haversineMeters", () => {
});
describe("buildGpxDocument", () => {
it("writes the instant of a stored sample object", () => {
const gpx = buildGpxDocument({
coordinates: [
[11.0, 50.0],
[11.001, 50.001],
],
timestamps: [
{ t: "2026-05-15T07:00:00Z", hr: 120 },
{ t: "2026-05-15T07:00:05Z", speedMs: 3.1 },
],
sportType: "running",
startedAt: "2026-05-15T07:00:00Z",
});
expect(gpx).toContain("<time>2026-05-15T07:00:00Z</time>");
expect(gpx).toContain("<time>2026-05-15T07:00:05Z</time>");
});
it("emits trkpt elements with elevation and time when present", () => {
const gpx = buildGpxDocument({
coordinates: [
+10
View File
@@ -42,6 +42,16 @@ describe("computeSplits", () => {
}
});
it("reads the stored sample shape, an instant with its channels", () => {
// Both ingests store `{ t, speedMs?, hr? }` per point; older tracks a
// bare instant. The two shapes time the same track identically.
const { coords, timestamps } = track(3200, 320, 960);
const samples = timestamps.map((t, i) => ({ t, hr: 120 + (i % 30) }));
const splits = computeSplits(coords, samples);
expect(splits).not.toBeNull();
expect(splits).toEqual(computeSplits(coords, timestamps));
});
it("returns null without aligned timestamps", () => {
const { coords } = track(3000, 300, 900);
expect(computeSplits(coords, null)).toBeNull();
+1 -1
View File
@@ -237,7 +237,7 @@ async function readEnrichment(
: null;
const splits =
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
? computeSplits(geometryCoords, row.route.sampleTimestamps as string[])
? computeSplits(geometryCoords, row.route.sampleTimestamps)
: null;
// Sport context: the user's own last-180-days average for this
+17
View File
@@ -0,0 +1,17 @@
/**
* One point of a stored route's `sampleTimestamps`, aligned with the
* geometry's coordinates. Both ingests store `{ t, speedMs?, hr? }`; tracks
* written before the per-point channels carry a bare ISO instant. Every
* reader goes through `routeSampleInstant` so neither shape is skipped.
*/
export type RouteSample = string | { t: string; speedMs?: number; hr?: number };
/** The ISO instant of one sample, or null when it carries none. */
export function routeSampleInstant(sample: unknown): string | null {
if (typeof sample === "string") return sample.length > 0 ? sample : null;
if (typeof sample === "object" && sample !== null && "t" in sample) {
const t = (sample as { t: unknown }).t;
return typeof t === "string" && t.length > 0 ? t : null;
}
return null;
}
+5 -3
View File
@@ -16,6 +16,7 @@
* correction squashes the longitude axis by `cos(midLat)` so one screen
* unit east equals one screen unit north in real metres.
*/
import { routeSampleInstant } from "@/lib/workouts/route-samples";
export type RouteCoordinate =
[number, number] | [number, number, number] | number[];
@@ -323,7 +324,8 @@ function escapeXml(value: string): string {
*/
export function buildGpxDocument(params: {
coordinates: readonly RouteCoordinate[];
timestamps?: readonly string[] | null;
/** The stored samples: an instant each, or `{ t, … }` (`RouteSample`). */
timestamps?: readonly unknown[] | null;
sportType: string;
startedAt: string;
}): string {
@@ -336,8 +338,8 @@ export function buildGpxDocument(params: {
if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) return;
const parts = [`<trkpt lat="${lat}" lon="${lon}">`];
if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`<ele>${c[2]}</ele>`);
const ts = timestamps?.[i];
if (typeof ts === "string" && ts.length > 0) {
const ts = routeSampleInstant(timestamps?.[i]);
if (ts) {
parts.push(`<time>${escapeXml(ts)}</time>`);
}
parts.push(`</trkpt>`);
+4 -2
View File
@@ -14,6 +14,7 @@ import {
haversineMeters,
type RouteCoordinate,
} from "@/lib/workouts/route-svg";
import { routeSampleInstant } from "@/lib/workouts/route-samples";
export interface WorkoutSplit {
/** 1-based kilometre index. */
@@ -34,7 +35,8 @@ const KM = 1000;
*/
export function computeSplits(
coordinates: readonly RouteCoordinate[],
timestamps: readonly string[] | null | undefined,
/** The stored samples: an instant each, or `{ t, … }` (`RouteSample`). */
timestamps: readonly unknown[] | null | undefined,
): WorkoutSplit[] | null {
if (!timestamps || timestamps.length !== coordinates.length) return null;
@@ -43,7 +45,7 @@ export function computeSplits(
coordinates.forEach((c, i) => {
const [lonLat] = extractLonLat([c]);
if (!lonLat) return;
const tMs = Date.parse(timestamps[i] ?? "");
const tMs = Date.parse(routeSampleInstant(timestamps[i]) ?? "");
if (!Number.isFinite(tMs)) return;
pts.push({ lon: lonLat[0], lat: lonLat[1], tMs });
});
@@ -1,23 +1,23 @@
{
"status": 500,
"status": 422,
"body": {
"data": null,
"error": "Interner Serverfehler",
"error": "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
"meta": {
"errorCode": "http.internal",
"errorCode": "ai.provider.none",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 500,
"status": 422,
"action": null,
"meta": null,
"warnings": null,
"error": {
"type": "TypeError",
"message": "Invalid authentication tag length: 0"
}
"warnings": [
"ai: a stored provider key could not be opened",
"ai: a stored provider key could not be opened"
],
"error": null
}
],
"effects": [],
@@ -14,6 +14,19 @@
"confidence": "hoch"
},
"disclaimer": "Consult your doctor.",
"dailyBriefing": {
"paragraph": "Your readings held steady this week, with no surprises.",
"signalsOfDay": [
{
"sourceMetric": "weight",
"tone": "info",
"headline": "Weight is steady",
"nudge": "Keep weighing in at the same time each morning.",
"delta": null
}
],
"keyFindings": []
},
"briefingGeneratedAt": "anchor+0d+12:00:00"
},
"cached": false,
@@ -222,6 +235,19 @@
"confidence": "hoch"
},
"disclaimer": "Consult your doctor.",
"dailyBriefing": {
"paragraph": "Your readings held steady this week, with no surprises.",
"signalsOfDay": [
{
"sourceMetric": "weight",
"tone": "info",
"headline": "Weight is steady",
"nudge": "Keep weighing in at the same time each morning.",
"delta": null
}
],
"keyFindings": []
},
"briefingGeneratedAt": "anchor+0d+12:00:00"
},
"snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a",
@@ -83,7 +83,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-15T18:00:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": false,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -89,7 +89,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": "2026-10-16T09:30:00.000Z",
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
@@ -63,7 +63,17 @@
"courses": [],
"courseCount": 0,
"previousCourseEndedOn": null,
"canStartCourse": false
"canStartCourse": false,
"nextDueAt": null,
"remindersActive": false,
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": false,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
"ingredients": [],
"related": []
},
"error": null
},
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more