mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'release/v1.46.0' into feat/v146-ui
This commit is contained in:
commit
4748091636
156 files changed
+2819
-1881
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12719,6 +12719,9 @@
|
||||
"rejectedDoseUpdate": "{name} 님이 복용 기록을 수정하려다 거절됐어요",
|
||||
"rejectedDoseImport": "{name} 님이 복용 기록을 불러오려다 거절됐어요",
|
||||
"rejectedTrackedValue": "{name} 님이 추적 값을 기록하려다 거절됐어요",
|
||||
"rejectedTrackedValueTypeCreate": "{name} 님이 추적 값 종류를 추가하려다 거절됐어요",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} 님이 추적 값 종류를 수정하려다 거절됐어요",
|
||||
"rejectedMedicationLayout": "{name} 님이 약 목록 순서를 바꾸려다 거절됐어요",
|
||||
"deniedDoseImport": "{name} 님이 시작한 복용 기록 불러오기를 대기열에 넣지 못했어요",
|
||||
"anamnesisFactCreate": "{name} 님이 건강 배경 항목을 추가했어요",
|
||||
"customMetricCreate": "{name} 님이 추적 값 종류를 추가했어요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+6
-12
@@ -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
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 */
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }] }),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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([
|
||||
|
||||
Vendored
+13
-16
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
+9
-9
@@ -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": [],
|
||||
|
||||
+26
@@ -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",
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
|
||||
},
|
||||
|
||||
+11
-1
@@ -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
Reference in new issue
Block a user