From b4166cdc84356d81f9d50cad69489262651f90ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:32:10 +0200 Subject: [PATCH 01/11] fix(logging): keep detached work from renaming the request's wide event Work a request starts and does not wait for runs inside the request's context, so 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. runDetached starts such work so that its action is dropped for the event it was detached from; meta, warnings and the auth it reads stay as they were, and an event the work opens for itself keeps its own action. fireAndForget now takes a function and starts it detached, and every trackBackgroundTask handle on a request path is built with runDetached, which a structural guard holds. --- .../background-task-settle-guard.test.ts | 31 ++++--- .../baselines/source-file-lines.json | 4 +- src/__tests__/detached-work-guard.test.ts | 93 +++++++++++++++++++ src/app/api/consent/ai/route.ts | 9 +- src/app/api/labs/ocr/commit/route.ts | 2 +- src/app/api/labs/route.ts | 2 +- src/lib/cache/server-cache.ts | 29 +++--- src/lib/fitbit/jobs/sync-handlers.ts | 2 +- src/lib/jobs/boss-ready.ts | 19 ++-- src/lib/jobs/import-tail.ts | 9 +- src/lib/jobs/reminder/google-health-sync.ts | 4 +- src/lib/jobs/reminder/poll-cohort.ts | 2 +- src/lib/jobs/reminder/whoop-sync.ts | 2 +- src/lib/jobs/reminder/withings-sync.ts | 6 +- src/lib/logging/__tests__/detached.test.ts | 82 ++++++++++++++++ .../logging/__tests__/fire-and-forget.test.ts | 10 +- src/lib/logging/context.ts | 10 +- src/lib/logging/detached.ts | 47 ++++++++++ src/lib/logging/fire-and-forget.ts | 16 ++-- src/lib/measurements/batch-ingest/report.ts | 2 +- .../measurements/create/create-readings.ts | 25 ++--- .../measurements/create/create-sleep-stage.ts | 2 +- .../after-measurement-mutation.test.ts | 34 +++++++ src/lib/rollups/after-measurement-mutation.ts | 9 +- src/lib/rollups/mood-rollups.ts | 39 ++++---- 25 files changed, 386 insertions(+), 104 deletions(-) create mode 100644 src/__tests__/detached-work-guard.test.ts create mode 100644 src/lib/logging/__tests__/detached.test.ts create mode 100644 src/lib/logging/detached.ts diff --git a/src/__tests__/background-task-settle-guard.test.ts b/src/__tests__/background-task-settle-guard.test.ts index 322c7dd6f..e6b564813 100644 --- a/src/__tests__/background-task-settle-guard.test.ts +++ b/src/__tests__/background-task-settle-guard.test.ts @@ -145,9 +145,10 @@ describe("background-task settle contract", () => { it("registers what fireAndForget starts", async () => { let release: () => void = () => {}; fireAndForget( - new Promise((resolve) => { - release = resolve; - }), + () => + new Promise((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((resolve) => - setTimeout(() => { - innerDone = true; - resolve(); - }, 10), - ), - { action: "guard.background.cascade.inner" }, - ); - }), + () => + Promise.resolve().then(() => { + fireAndForget( + () => + new Promise((resolve) => + setTimeout(() => { + innerDone = true; + resolve(); + }, 10), + ), + { action: "guard.background.cascade.inner" }, + ); + }), { action: "guard.background.cascade.outer" }, ); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 0989373ab..822bd738b 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 101, - "total": 116900, + "total": 116897, "entries": { "app/medications/page-client.tsx": 839, "app/page-client.tsx": 2324, @@ -48,7 +48,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, diff --git a/src/__tests__/detached-work-guard.test.ts b/src/__tests__/detached-work-guard.test.ts new file mode 100644 index 000000000..8f3992048 --- /dev/null +++ b/src/__tests__/detached-work-guard.test.ts @@ -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 = { + "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); + }); +}); diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 86421e8ca..13d49882f 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -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" }, ); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 9db6f0e75..cade37169 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -402,7 +402,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", }); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 4732acab9..65373f80c 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -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", }); diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 48a2dd842..265d1215f 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -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 { @@ -406,23 +407,19 @@ export class ServerCache { // 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({ diff --git a/src/lib/fitbit/jobs/sync-handlers.ts b/src/lib/fitbit/jobs/sync-handlers.ts index 3b9ea163d..148c89428 100644 --- a/src/lib/fitbit/jobs/sync-handlers.ts +++ b/src/lib/fitbit/jobs/sync-handlers.ts @@ -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", }); } diff --git a/src/lib/jobs/boss-ready.ts b/src/lib/jobs/boss-ready.ts index 1388fcfff..0c06af8f2 100644 --- a/src/lib/jobs/boss-ready.ts +++ b/src/lib/jobs/boss-ready.ts @@ -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"; } diff --git a/src/lib/jobs/import-tail.ts b/src/lib/jobs/import-tail.ts index a63dbf71d..0df38f7ca 100644 --- a/src/lib/jobs/import-tail.ts +++ b/src/lib/jobs/import-tail.ts @@ -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, diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index 0e49f4f64..44e59eadf 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -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", }); } diff --git a/src/lib/jobs/reminder/poll-cohort.ts b/src/lib/jobs/reminder/poll-cohort.ts index 7bd205f59..25f62ebde 100644 --- a/src/lib/jobs/reminder/poll-cohort.ts +++ b/src/lib/jobs/reminder/poll-cohort.ts @@ -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", }); } diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts index 429463e18..bf22df904 100644 --- a/src/lib/jobs/reminder/whoop-sync.ts +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -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", }); } diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index af4447739..ca5de77a6 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -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", }); } diff --git a/src/lib/logging/__tests__/detached.test.ts b/src/lib/logging/__tests__/detached.test.ts new file mode 100644 index 000000000..0166e3dd4 --- /dev/null +++ b/src/lib/logging/__tests__/detached.test.ts @@ -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"); + }); +}); diff --git a/src/lib/logging/__tests__/fire-and-forget.test.ts b/src/lib/logging/__tests__/fire-and-forget.test.ts index a0fbb3e23..608151d68 100644 --- a/src/lib/logging/__tests__/fire-and-forget.test.ts +++ b/src/lib/logging/__tests__/fire-and-forget.test.ts @@ -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(); diff --git a/src/lib/logging/context.ts b/src/lib/logging/context.ts index a7e51266b..82ea9fd4f 100644 --- a/src/lib/logging/context.ts +++ b/src/lib/logging/context.ts @@ -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); diff --git a/src/lib/logging/detached.ts b/src/lib/logging/detached.ts new file mode 100644 index 000000000..7b241d6bb --- /dev/null +++ b/src/lib/logging/detached.ts @@ -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(); + +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(start: () => Promise): Promise { + const parent = currentEvent(); + if (!parent) return start(); + try { + return detachedFrom.run(parent, start); + } catch (error) { + return Promise.reject(error); + } +} diff --git a/src/lib/logging/fire-and-forget.ts b/src/lib/logging/fire-and-forget.ts index a6f06895c..6a45b2535 100644 --- a/src/lib/logging/fire-and-forget.ts +++ b/src/lib/logging/fire-and-forget.ts @@ -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 `..` 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, + start: () => Promise, context: { action: string; meta?: Record }, ): 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: { diff --git a/src/lib/measurements/batch-ingest/report.ts b/src/lib/measurements/batch-ingest/report.ts index 8cce1851e..d47ebe43c 100644 --- a/src/lib/measurements/batch-ingest/report.ts +++ b/src/lib/measurements/batch-ingest/report.ts @@ -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 { diff --git a/src/lib/measurements/create/create-readings.ts b/src/lib/measurements/create/create-readings.ts index 0ab7ea82d..c89a2fc5f 100644 --- a/src/lib/measurements/create/create-readings.ts +++ b/src/lib/measurements/create/create-readings.ts @@ -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" }, ); diff --git a/src/lib/measurements/create/create-sleep-stage.ts b/src/lib/measurements/create/create-sleep-stage.ts index f42b235d9..79a7da6f8 100644 --- a/src/lib/measurements/create/create-sleep-stage.ts +++ b/src/lib/measurements/create/create-sleep-stage.ts @@ -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") { diff --git a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts index 17fd3ca91..5d330e882 100644 --- a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts +++ b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts @@ -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"); + }); }); diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts index 6cd1f834e..1dfaa8955 100644 --- a/src/lib/rollups/after-measurement-mutation.ts +++ b/src/lib/rollups/after-measurement-mutation.ts @@ -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); }), ); diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index 4dfe341e7..cf249bb05 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -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); - }); - }), + }), + ), ), ); } From 06d55afc4610229c212d2bc0c06f2cfaffd4305e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:33:05 +0200 Subject: [PATCH 02/11] fix(deps): raise the ip-address floor to 10.7.4 10.7.3 and earlier take exponential time on a crafted Address6 zone identifier. The copy under the MCP SDK's rate limiter reaches runtime, and the one under the registry client resolves to the same version now. --- pnpm-lock.yaml | 18 ++++++------------ pnpm-workspace.yaml | 5 +++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f8855321..9343b3210 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -4860,12 +4860,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: @@ -11755,7 +11751,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 @@ -12165,9 +12161,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: {} @@ -13896,7 +13890,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): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f95c35f1..b70efd59f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 From 976b2ba0bcb36918c8f1aca19e2d5d3c6d199adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:37:58 +0200 Subject: [PATCH 03/11] fix(workouts): read the stored route sample shape for splits and the GPX export Both ingests store each route point's sample as { t, speedMs?, hr? }, while computeSplits parsed every sample as a bare instant and the GPX export skipped anything that was not a string. Splits were therefore null for every ingested track, and an exported track carried no times. One reader, routeSampleInstant, takes either shape. The workout detail walk is re-pinned: its ingested run now answers its first kilometre split. --- src/hooks/use-workouts.ts | 3 ++- src/lib/workouts/__tests__/route-svg.test.ts | 17 +++++++++++++++++ src/lib/workouts/__tests__/splits.test.ts | 10 ++++++++++ src/lib/workouts/detail.ts | 2 +- src/lib/workouts/route-samples.ts | 17 +++++++++++++++++ src/lib/workouts/route-svg.ts | 8 +++++--- src/lib/workouts/splits.ts | 6 ++++-- .../workout-detail.get-01-canonical.json.snap | 8 +++++++- .../workout-detail.get-02-compact.json.snap | 8 +++++++- 9 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 src/lib/workouts/route-samples.ts diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 3485551dd..d17aa8d97 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -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 diff --git a/src/lib/workouts/__tests__/route-svg.test.ts b/src/lib/workouts/__tests__/route-svg.test.ts index 0061b74c2..c582de0f5 100644 --- a/src/lib/workouts/__tests__/route-svg.test.ts +++ b/src/lib/workouts/__tests__/route-svg.test.ts @@ -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(""); + expect(gpx).toContain(""); + }); + it("emits trkpt elements with elevation and time when present", () => { const gpx = buildGpxDocument({ coordinates: [ diff --git a/src/lib/workouts/__tests__/splits.test.ts b/src/lib/workouts/__tests__/splits.test.ts index 6b335053a..48e9e6074 100644 --- a/src/lib/workouts/__tests__/splits.test.ts +++ b/src/lib/workouts/__tests__/splits.test.ts @@ -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(); diff --git a/src/lib/workouts/detail.ts b/src/lib/workouts/detail.ts index ed1d6e026..126fe6943 100644 --- a/src/lib/workouts/detail.ts +++ b/src/lib/workouts/detail.ts @@ -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 diff --git a/src/lib/workouts/route-samples.ts b/src/lib/workouts/route-samples.ts new file mode 100644 index 000000000..11d403518 --- /dev/null +++ b/src/lib/workouts/route-samples.ts @@ -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; +} diff --git a/src/lib/workouts/route-svg.ts b/src/lib/workouts/route-svg.ts index 2c4d29952..c07e455c4 100644 --- a/src/lib/workouts/route-svg.ts +++ b/src/lib/workouts/route-svg.ts @@ -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 = [``]; if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`${c[2]}`); - const ts = timestamps?.[i]; - if (typeof ts === "string" && ts.length > 0) { + const ts = routeSampleInstant(timestamps?.[i]); + if (ts) { parts.push(``); } parts.push(``); diff --git a/src/lib/workouts/splits.ts b/src/lib/workouts/splits.ts index 3a8377c1b..6e353647d 100644 --- a/src/lib/workouts/splits.ts +++ b/src/lib/workouts/splits.ts @@ -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 }); }); diff --git a/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap b/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap index b4b299c13..1b9359e7a 100644 --- a/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap +++ b/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap @@ -145,7 +145,13 @@ } ] }, - "splits": null, + "splits": [ + { + "km": 1, + "durationSec": 530, + "paceSecPerKm": 530 + } + ], "sportContext": { "count": 1, "avgDurationSec": 1800, diff --git a/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap b/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap index d1510e4c2..b2b4dc296 100644 --- a/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap +++ b/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap @@ -115,7 +115,13 @@ } ] }, - "splits": null, + "splits": [ + { + "km": 1, + "durationSec": 530, + "paceSecPerKm": 530 + } + ], "sportContext": { "count": 1, "avgDurationSec": 1800, From b02caed22123bd63ead24017fa0122fc4b15aebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:44:50 +0200 Subject: [PATCH 04/11] fix(ai): treat a stored provider key that cannot be opened as no key A key id retired from ENCRYPTION_KEYS, or a corrupt row, made decrypt throw inside the provider builders, and a briefing generation answered 500. An unreadable key now drops its entry from the chain like a missing one, with a warning on the event, so the generation answers the no-provider 422 (ai.provider.none). The connection test reads it as no stored key. The parity walk holds that case to the contract again. --- src/lib/ai/provider.ts | 72 ++++++++++++++----- ...ghts-generate.P09-unreadable-key.json.snap | 18 ++--- .../parity/insights-generate.parity.test.ts | 6 +- 3 files changed, 64 insertions(+), 32 deletions(-) diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 9c788bbee..5343559dc 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -34,6 +34,7 @@ import { } from "@/lib/sharing/provider-work-authority"; import { memoizePerRequest } from "@/lib/request-cache"; import { emitSignal } from "@/lib/logging/signal"; +import { getEvent } from "@/lib/logging/context"; import { acquireProviderTokenRefreshLock, PROVIDER_REFRESH_TRANSACTION_OPTIONS, @@ -370,6 +371,22 @@ function isOperatorOwned(row: { role: string } | null | undefined): boolean { return row?.role === "ADMIN"; } +/** + * 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. + */ +function openStoredKey(encrypted: string): string | null { + try { + return decrypt(encrypted); + } catch { + getEvent()?.addWarning("ai: a stored provider key could not be opened"); + return null; + } +} + /** * v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's * three dedicated columns. The signature names every column this provider is @@ -400,8 +417,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 +445,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), @@ -455,8 +480,10 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { // 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 = openStoredKey(row.aiOpenaiKeyEncrypted); + if (apiKey === null) return null; return new OpenAIClient({ - apiKey: decrypt(row.aiOpenaiKeyEncrypted), + apiKey, model: row.aiModel ?? "gpt-4o", baseUrl: "https://api.openai.com/v1", }); @@ -515,17 +542,20 @@ async function resolveAdminProvider(): Promise { 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. @@ -1352,8 +1382,10 @@ async function resolveProviderForType( case "openai": { const enc = ctx.userRow?.aiOpenaiKeyEncrypted; if (!enc) return null; + const apiKey = openStoredKey(enc); + if (apiKey === null) return null; return new OpenAIClient({ - apiKey: decrypt(enc), + apiKey, model: ctx.userRow?.aiModel ?? "gpt-4o", baseUrl: "https://api.openai.com/v1", }); @@ -1361,17 +1393,21 @@ async function resolveProviderForType( case "anthropic": { const enc = ctx.userRow?.aiAnthropicKeyEncrypted; if (!enc) return null; + const apiKey = openStoredKey(enc); + 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 +1541,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 +1570,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 +1619,7 @@ async function resolveProviderForTestUnbound( const apiKey = override.compatKey?.trim() || (stored?.aiCompatKeyEncrypted - ? decrypt(stored.aiCompatKeyEncrypted) + ? (openStoredKey(stored.aiCompatKeyEncrypted) ?? "") : ""); return new OpenAIClient({ apiKey, @@ -1608,7 +1644,7 @@ async function resolveProviderForTestUnbound( const userKey = override.openaiKey?.trim() || (stored?.aiOpenaiKeyEncrypted - ? decrypt(stored.aiOpenaiKeyEncrypted) + ? (openStoredKey(stored.aiOpenaiKeyEncrypted) ?? "") : ""); if (userKey) { return new OpenAIClient({ diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P09-unreadable-key.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P09-unreadable-key.json.snap index a0924a58b..6e6417972 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-generate.P09-unreadable-key.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P09-unreadable-key.json.snap @@ -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": "" } }, "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": [], diff --git a/tests/integration/routes/parity/insights-generate.parity.test.ts b/tests/integration/routes/parity/insights-generate.parity.test.ts index 86b33a7fd..81d01d215 100644 --- a/tests/integration/routes/parity/insights-generate.parity.test.ts +++ b/tests/integration/routes/parity/insights-generate.parity.test.ts @@ -797,11 +797,7 @@ describe("POST /api/insights/generate parity", () => { await post( "P09-unreadable-key", { force: true }, - { - auth: unreadableKey, - userId: UNREADABLE_KEY_ID, - undeclared: "an unreadable stored key ends in 500 (pinned as it is)", - }, + { auth: unreadableKey, userId: UNREADABLE_KEY_ID }, ); }); From cf37f833e81e4f01367c3ea43c3bea28e1a19148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:44:50 +0200 Subject: [PATCH 05/11] fix(insights): keep the briefing blocks of a reply that validates The briefing prompt asks for dailyBriefing, trendAnnotations and storyboardAnnotations beside the insight fields, and the briefing card, the dashboard snapshot and the today read consume them. insightResultSchema was a strict object, so it dropped exactly those blocks from a reply that validated, while a reply that failed validation kept them. It is a loose object now: the named fields are checked and the rest of the reply stays. --- src/lib/ai/types.ts | 11 +++++- .../__tests__/parse-briefing-reply.test.ts | 37 +++++++++++++++++++ .../insights/briefing-request/completion.ts | 4 +- src/lib/insights/comprehensive-generate.ts | 4 +- ...sights-generate.P11-strict-reply.json.snap | 26 +++++++++++++ 5 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index bf4b47fc2..4dcbdc1bf 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -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([ diff --git a/src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts b/src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts new file mode 100644 index 000000000..68de24fa8 --- /dev/null +++ b/src/lib/insights/briefing-request/__tests__/parse-briefing-reply.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/briefing-request/completion.ts b/src/lib/insights/briefing-request/completion.ts index a63f86804..923c29720 100644 --- a/src/lib/insights/briefing-request/completion.ts +++ b/src/lib/insights/briefing-request/completion.ts @@ -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, diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 137f488aa..8ae3feb59 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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. */ diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap index 646d8ce72..5a2a4783c 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap @@ -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", From 89b618920621af73fd640cc736bba48ab72f87ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:54:56 +0200 Subject: [PATCH 06/11] test(measurements): call the fire-and-forget mock with a starter function --- src/app/api/measurements/__tests__/arrival-wiring.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/api/measurements/__tests__/arrival-wiring.test.ts b/src/app/api/measurements/__tests__/arrival-wiring.test.ts index ded4e7f73..2e86f8ae0 100644 --- a/src/app/api/measurements/__tests__/arrival-wiring.test.ts +++ b/src/app/api/measurements/__tests__/arrival-wiring.test.ts @@ -57,7 +57,8 @@ vi.mock("@/lib/logging/context", async (importOriginal) => ({ annotate: vi.fn(), })); vi.mock("@/lib/logging/fire-and-forget", () => ({ - fireAndForget: (promise: Promise) => void promise.catch(() => {}), + fireAndForget: (start: () => Promise) => + void start().catch(() => {}), })); vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn(() => null), From 55a6287069c90235e2644584d9b452eb98c5314b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:54:57 +0200 Subject: [PATCH 07/11] fix(audit): file every refused request through auditLog() Twelve routes wrote their .validation-failed row with a bare prisma.auditLog.create, so they skipped the two decisions auditLog makes: an owner's own refused request stays on the wide event (meta.rejected_attempt) instead of becoming a row, and a delegate's names the delegate as the actor. The bulk medication and mood ingests, the layout, overlay, device, health-sync, metric and ingest routes all go through auditLog now, and a structural guard keeps raw writes to the one writer and the briefing failure marker. The unit tests assert the auditLog call, and the bulk walks are re-pinned without the owner's refusal row. --- src/__tests__/audit-write-path-guard.test.ts | 61 +++++++++++++++++++ .../[id]/entries/[entryId]/route.ts | 17 ++---- src/app/api/custom-metrics/[id]/route.ts | 23 +++---- .../custom-metrics/__tests__/route.test.ts | 2 + src/app/api/custom-metrics/route.ts | 17 ++---- .../__tests__/route.test.ts | 10 ++- .../dashboard/chart-overlay-prefs/route.ts | 18 +++--- src/app/api/devices/__tests__/route.test.ts | 17 +++--- src/app/api/devices/route.ts | 17 ++---- .../ingest/medication/__tests__/route.test.ts | 17 +++--- src/app/api/ingest/medication/route.ts | 17 ++---- .../insights/layout/__tests__/route.test.ts | 30 +++++---- src/app/api/insights/layout/route.ts | 18 +++--- .../healthkit/__tests__/route.test.ts | 16 ++--- src/app/api/integrations/healthkit/route.ts | 17 ++---- src/app/api/labs/ocr/commit/route.ts | 13 ++-- .../intake/bulk/__tests__/route.test.ts | 16 ++--- src/app/api/medications/intake/bulk/route.ts | 19 +++--- .../layout/__tests__/route.test.ts | 11 ++-- src/app/api/medications/layout/route.ts | 18 +++--- .../mood-entries/bulk/__tests__/route.test.ts | 16 ++--- src/app/api/mood-entries/bulk/route.ts | 19 +++--- ...dication-intake-bulk.04b-invalid.json.snap | 21 +------ ...take-bulk.04c-apple-not-mirrored.json.snap | 18 ------ ...edication-intake-bulk.05a-native.json.snap | 18 ------ ...ion-intake-bulk.05b-narrow-token.json.snap | 18 ------ ...ication-intake-bulk.05c-delegate.json.snap | 18 ------ ...cation-intake-bulk.05d-anonymous.json.snap | 18 ------ .../mood-entries-bulk.05-invalid.json.snap | 3 +- .../medication-intake-bulk.parity.test.ts | 13 ++-- 30 files changed, 217 insertions(+), 319 deletions(-) create mode 100644 src/__tests__/audit-write-path-guard.test.ts diff --git a/src/__tests__/audit-write-path-guard.test.ts b/src/__tests__/audit-write-path-guard.test.ts new file mode 100644 index 000000000..43f6d5bf9 --- /dev/null +++ b/src/__tests__/audit-write-path-guard.test.ts @@ -0,0 +1,61 @@ +/** + * 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 (`.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 = { + "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(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); + }); +}); diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index 2f1d171fe..cd30b6315 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -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; diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 3a2ec7842..2b149bd4d 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -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; diff --git a/src/app/api/custom-metrics/__tests__/route.test.ts b/src/app/api/custom-metrics/__tests__/route.test.ts index 57a10098b..50ee33ccf 100644 --- a/src/app/api/custom-metrics/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 74bf526ba..dfc9aa12e 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -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; diff --git a/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts index 65db8fd81..29b6c0ccf 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index ab5c51a89..ded1da044 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -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 */ + }); } }, }); diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 88c52f32d..ef65576c7 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -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" }), ); diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 5ad6c53a1..36f24678b 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -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; diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 421dee241..aae965196 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index f39f89c08..35176e405 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -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; diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index 3be6182da..341812186 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -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(); + 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. diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 640720b0d..3243bfe47 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -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); } diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 7804fad49..c7d7c6176 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -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" }] }), ); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 1db234f6c..62a991514 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -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); } diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index cade37169..e3a2bce86 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -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; diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index bd530c53c..006d9c7ee 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -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" }], diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 80d490cb6..9f0c0ca27 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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", }); diff --git a/src/app/api/medications/layout/__tests__/route.test.ts b/src/app/api/medications/layout/__tests__/route.test.ts index 3e02ca491..cc8f500b6 100644 --- a/src/app/api/medications/layout/__tests__/route.test.ts +++ b/src/app/api/medications/layout/__tests__/route.test.ts @@ -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(); }); }); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts index 5434fa6c0..84b5a4add 100644 --- a/src/app/api/medications/layout/route.ts +++ b/src/app/api/medications/layout/route.ts @@ -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); } diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index f67b039eb..e77da8c5b 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -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: [ diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 407b3015e..685ee208a 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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 { 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", }); diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap index 4d2dad010..8d502393a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap @@ -30,7 +30,8 @@ }, "meta": { "write_bucket": "own", - "issue_count": 2 + "issue_count": 2, + "rejected_attempt": "medications.intake.bulk.validation-failed" }, "warnings": null, "error": null @@ -584,24 +585,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap index 4249b41f7..1b576c2b2 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap @@ -567,24 +567,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap index 6656a318d..7f14348a3 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap @@ -662,24 +662,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap index 216e2e279..8b38a8d26 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap @@ -617,24 +617,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap index ef83ea41b..e30efa5ac 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap @@ -619,24 +619,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap index 8fff49289..06466d200 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap @@ -617,24 +617,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap index a6898965c..6028d3721 100644 --- a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap +++ b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap @@ -30,7 +30,8 @@ }, "meta": { "write_bucket": "own", - "issue_count": 2 + "issue_count": 2, + "rejected_attempt": "mood.bulk.validation-failed" }, "warnings": null, "error": null diff --git a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts index ec3fc649b..f51313028 100644 --- a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts @@ -94,7 +94,6 @@ vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => { const prisma = getPrismaClient(); const NOW = new Date("2026-10-15T12:00:00.000Z"); const ROUTE = "/api/medications/intake/bulk"; -const INVALID_ACTION = "medications.intake.bulk.validation-failed"; let ownerId: string; let userIds: string[]; @@ -130,8 +129,8 @@ async function syncState() { } /** - * An audit row a refusal writes without awaiting it: the schema refusal's - * own, and the narrow token's `auth.bearer.failure`. + * An audit row a refusal writes without awaiting it: the narrow token's + * `auth.bearer.failure`. (An owner's own schema refusal files none.) */ async function auditRows(action: string, count: number): Promise { // Polled on the real timer: the pinned clock stamps the rows read back, @@ -416,11 +415,9 @@ describe("POST /api/medications/intake/bulk parity", () => { medicationId: med.daily, })), }); - await post( - "04b-invalid", - { entries: [{ medicationId: "", takenAt: "never" }] }, - { settle: () => auditRows(INVALID_ACTION, 1) }, - ); + await post("04b-invalid", { + entries: [{ medicationId: "", takenAt: "never" }], + }); await post("04c-apple-not-mirrored", { entries: [ { From 521b85bfbdc6ebed8a135c6d6dc7e284ad295868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 17:10:02 +0200 Subject: [PATCH 08/11] fix(openapi): declare the mirror replay 200 of a medication create A mirror create re-posting a pair the caller already holds has always answered the existing medication with 200, as the description says; only the 201 was declared. --- docs/api/openapi.yaml | 7 +++++++ src/lib/openapi/routes/medications/paths.ts | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3933181fb..ee9bd53e2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 8ebdf52e0..d5ef6928f 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -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 = { "/api/medications/intake": { get: { @@ -969,12 +976,14 @@ export const medicationPaths: NonNullable = { "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, From 51cba5275126d6bd4f88954752ea838e90922b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 17:10:02 +0200 Subject: [PATCH 09/11] fix(medications): answer writes with the shapes the contract declares The create, its mirror replay and the edit answered the written row without the read-model fields the declared resource carries (reminder state, stock and runway, actionability, course status, composition, links), while the detail read answered them. They answer the read model now, built from the row they just wrote (medicationDetail), so a client reads back exactly what GET /api/medications/{id} would say. A dose write that converged onto an existing slot answered the slot upsert's narrow row, without userId, medicationId, updatedAt, autoRecorded, autoMissed or attributionSource. The upsert reads and returns the whole event row, the same shape a write that inserted its own answers. The parity walks drop their contract skips and are re-pinned. --- .../medications/[id]/__tests__/route.test.ts | 33 +++ .../api/medications/__tests__/route.test.ts | 45 +++- src/lib/medications/create-medication.ts | 131 ++++------- src/lib/medications/read-medication.ts | 38 +++- .../apply-canonical-slot-write.test.ts | 15 +- src/lib/medications/scheduling/slot-upsert.ts | 34 +-- .../update-medication/update-medication.ts | 46 ++-- ...edication-detail.11-plain-fields.json.snap | 12 +- ...ation-detail.12-schedule-replace.json.snap | 12 +- ...dication-detail.13-schedule-echo.json.snap | 12 +- ...edication-detail.14-grace-bridge.json.snap | 12 +- .../medication-detail.15a-pause.json.snap | 12 +- .../medication-detail.15b-resume.json.snap | 12 +- ...cation-detail.16-auto-mark-taken.json.snap | 12 +- ...ation-detail.17a-custom-category.json.snap | 12 +- ...ion-detail.17b-native-other-kept.json.snap | 12 +- .../medication-detail.17c-web-other.json.snap | 12 +- ...-detail.18a-record-schedule-kept.json.snap | 12 +- ...edication-detail.18b-tracking-on.json.snap | 12 +- ...dication-detail.18c-tracking-off.json.snap | 12 +- .../medication-detail.19b-scheduled.json.snap | 12 +- .../medication-detail.19d-as-needed.json.snap | 12 +- .../medication-detail.20c-one-shot.json.snap | 12 +- ...medication-detail.21c-course-end.json.snap | 12 +- ...tion-detail.22-composition-units.json.snap | 22 +- .../medication-detail.24b-manager.json.snap | 12 +- ...ion-intake-event.03-move-by-band.json.snap | 54 +++-- ...on-intake-event.04-pin-free-slot.json.snap | 58 +++-- ...medication-intake-event.07-unpin.json.snap | 62 +++--- ...on-intake-event.08-explicit-slot.json.snap | 66 +++--- ...-intake-status.12-take-slot-move.json.snap | 118 +++++----- ...medication-intake-status.13a-pin.json.snap | 126 ++++++----- ...on-intake-status.20a-writer-take.json.snap | 203 ++++++++++-------- ...edication-intake.01-take-in-band.json.snap | 80 ++++--- ...ation-intake.03a-skip-named-slot.json.snap | 86 ++++---- ...ication-intake.03b-skip-slotless.json.snap | 99 +++++---- ...intake.03d-owner-skip-standalone.json.snap | 80 ++++--- .../medication-intake.04a-pin.json.snap | 108 +++++----- ...ication-intake.05-named-backfill.json.snap | 114 +++++----- .../medication-intake.06a-body-key.json.snap | 124 ++++++----- ...medication-intake.07a-header-key.json.snap | 136 ++++++------ ...ion-intake.07b-header-key-replay.json.snap | 126 ++++++----- .../medication-intake.08-native.json.snap | 146 +++++++------ ...ation-intake.09b-owner-skip-flip.json.snap | 140 ++++++------ ...edication-intake.10a-writer-take.json.snap | 162 +++++++------- ...ication-intake.11b-site-and-dose.json.snap | 110 +++++----- .../medication-intake.12-one-shot.json.snap | 118 +++++----- .../medications.11-plain.json.snap | 12 +- .../medications.12-full.json.snap | 12 +- .../medications.13a-as-needed.json.snap | 12 +- .../medications.13b-one-shot.json.snap | 12 +- ...ications.13c-prn-and-legacy-days.json.snap | 12 +- .../medications.13d-ended-window.json.snap | 12 +- .../medications.15a-mirror.json.snap | 12 +- .../medications.15b-mirror-replay.json.snap | 16 +- ...medications.17a-idempotent-first.json.snap | 12 +- ...edications.17b-idempotent-replay.json.snap | 12 +- .../medications.18a-writer.json.snap | 12 +- .../parity/medication-detail.parity.test.ts | 10 - .../medication-intake-event.parity.test.ts | 51 ++--- .../medication-intake-status.parity.test.ts | 49 ++--- .../parity/medication-intake.parity.test.ts | 10 - .../routes/parity/medications.parity.test.ts | 10 - 63 files changed, 1839 insertions(+), 1323 deletions(-) diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 8cc13470d..a1791b827 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -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(); + 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[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), diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index d91bb73e9..1159f63a2 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -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(); + 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[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(); }); diff --git a/src/lib/medications/create-medication.ts b/src/lib/medications/create-medication.ts index 4b9a370de..11162bfe6 100644 --- a/src/lib/medications/create-medication.ts +++ b/src/lib/medications/create-medication.ts @@ -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; type ScheduleInput = NonNullable[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 & { - id: string; - unitsPerDose: unknown; - trackIntake: boolean; - schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>; - }, + user: ReadUser, + medication: Medication & { schedules: MedicationSchedule[] }, ): Promise { - 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, externalId: string, ): Promise { + 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; + user: ReadUser; data: CreateMedicationInput; ipAddress: string | null; }): Promise { @@ -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 }; } diff --git a/src/lib/medications/read-medication.ts b/src/lib/medications/read-medication.ts index 9b1c02f85..248235e3e 100644 --- a/src/lib/medications/read-medication.ts +++ b/src/lib/medications/read-medication.ts @@ -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; +export type ReadUser = Pick; 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> { + 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); diff --git a/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts b/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts index abfd20310..e08399a02 100644 --- a/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts +++ b/src/lib/medications/scheduling/__tests__/apply-canonical-slot-write.test.ts @@ -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 { + 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, }; } diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 69faf5844..13a791050 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -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; -/** 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 { diff --git a/src/lib/medications/update-medication/update-medication.ts b/src/lib/medications/update-medication/update-medication.ts index be7c9ba20..b698fb894 100644 --- a/src/lib/medications/update-medication/update-medication.ts +++ b/src/lib/medications/update-medication/update-medication.ts @@ -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; +type EditUser = ReadUser; type EditedMedication = Awaited>; 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 }; } diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap index caac60735..a51c9f9c7 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap index 9253f75d6..633798338 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap index 76cbe084b..798ac25e4 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap index 9592315ae..b2c594dfe 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap index 58c4631c6..341c62676 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap index 80d5f956d..eb29dbaf6 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap index f960f6d45..d9b77b14f 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap index 41c112d3d..1aff35567 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap index e537872e2..d6d69d3b3 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap index aa42bd9a1..8298a4f74 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap index 812cf0903..b8c24ee3e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap index 152c79133..fb5603aae 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap @@ -62,7 +62,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T05:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap index 160f86063..725e80fbf 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap index 18bea73e4..fe0b6e967 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap @@ -62,7 +62,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T06:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap index f1c85281a..582f4f13f 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap @@ -40,7 +40,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": null, + "remindersActive": false, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap index 7ca869539..0560579b9 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap @@ -71,7 +71,17 @@ ], "courseCount": 1, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-20T07:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "UPCOMING", + "intakeActionable": false, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap index e89aacbc9..7d472d215 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap @@ -79,7 +79,17 @@ ], "courseCount": 2, "previousCourseEndedOn": "2026-08-31", - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T06:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap index 50d79355e..02ed0c383 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap @@ -62,7 +62,27 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-17T07:00:00.000Z", + "remindersActive": false, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": 3, + "stockDosesRemaining": 1, + "runwayDays": 10, + "ingredients": [ + { + "nutrient": "vitamin_d", + "amountPerUnit": 25, + "unit": "ug", + "enteredAmount": 1000, + "enteredUnit": "IU", + "labelKey": "nutrients.names.vitamin_d", + "label": "Vitamin D" + } + ], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.24b-manager.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.24b-manager.json.snap index 46f368ca1..ee901103c 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-detail.24b-manager.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-detail.24b-manager.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-event.03-move-by-band.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-event.03-move-by-band.json.snap index cef0085b9..6136a16fc 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-event.03-move-by-band.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-event.03-move-by-band.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-14T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -18,7 +30,7 @@ "action": { "name": "medication.intake.update", "entity_type": "intake_event", - "entity_id": "" + "entity_id": "" }, "meta": { "medication_id": "" @@ -32,7 +44,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -42,7 +54,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "Parity one-shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -76,7 +88,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Parity tablet", "dose": "10mg", "treatmentClass": "GENERIC", @@ -171,7 +183,7 @@ "intakes": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-10T09:00:00.000Z", "takenAt": "2026-10-10T09:30:00.000Z", @@ -192,7 +204,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:20:00.000Z", @@ -234,7 +246,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:05:00.000Z", @@ -255,7 +267,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T08:40:00.000Z", @@ -276,7 +288,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -296,8 +308,8 @@ "deletedAt": null }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T08:10:00.000Z", @@ -318,7 +330,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", @@ -342,7 +354,7 @@ "inventoryItems": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "state": "IN_USE", "containerType": "OTHER", @@ -366,10 +378,10 @@ "audit": [ { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "eventId": "", + "eventId": "", "medicationId": "", "fields": [ "scheduledFor", @@ -384,7 +396,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -400,7 +412,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-event.04-pin-free-slot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-event.04-pin-free-slot.json.snap index 292c4f3a2..554560af1 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-event.04-pin-free-slot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-event.04-pin-free-slot.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-12T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "USER_PIN", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -18,7 +30,7 @@ "action": { "name": "medication.intake.update", "entity_type": "intake_event", - "entity_id": "" + "entity_id": "" }, "meta": { "medication_id": "" @@ -32,7 +44,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -42,7 +54,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "Parity one-shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -76,7 +88,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Parity tablet", "dose": "10mg", "treatmentClass": "GENERIC", @@ -171,7 +183,7 @@ "intakes": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-10T09:00:00.000Z", "takenAt": "2026-10-10T09:30:00.000Z", @@ -192,7 +204,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:20:00.000Z", @@ -234,7 +246,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:05:00.000Z", @@ -255,7 +267,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -276,7 +288,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T08:40:00.000Z", @@ -296,8 +308,8 @@ "deletedAt": null }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -318,7 +330,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T08:10:00.000Z", @@ -339,7 +351,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", @@ -363,7 +375,7 @@ "inventoryItems": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "state": "IN_USE", "containerType": "OTHER", @@ -387,7 +399,7 @@ "audit": [ { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -405,10 +417,10 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "eventId": "", + "eventId": "", "medicationId": "", "fields": [ "scheduledFor" @@ -421,7 +433,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -437,7 +449,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-event.07-unpin.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-event.07-unpin.json.snap index 99fba82d2..a0661d19b 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-event.07-unpin.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-event.07-unpin.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-13T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "USER_PIN", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -18,7 +30,7 @@ "action": { "name": "medication.intake.update", "entity_type": "intake_event", - "entity_id": "" + "entity_id": "" }, "meta": { "medication_id": "" @@ -32,7 +44,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -42,7 +54,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "Parity one-shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -76,7 +88,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Parity tablet", "dose": "10mg", "treatmentClass": "GENERIC", @@ -171,7 +183,7 @@ "intakes": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-10T09:00:00.000Z", "takenAt": "2026-10-10T09:30:00.000Z", @@ -192,7 +204,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:20:00.000Z", @@ -234,7 +246,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:05:00.000Z", @@ -254,8 +266,8 @@ "deletedAt": "2026-10-12T09:00:00.000Z" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -276,7 +288,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T08:40:00.000Z", @@ -297,7 +309,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -318,7 +330,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -339,7 +351,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T08:10:00.000Z", @@ -360,7 +372,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", @@ -384,7 +396,7 @@ "inventoryItems": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "state": "IN_USE", "containerType": "OTHER", @@ -408,7 +420,7 @@ "audit": [ { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -426,10 +438,10 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "eventId": "", + "eventId": "", "medicationId": "", "fields": [ "scheduledFor" @@ -442,7 +454,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -458,7 +470,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -474,7 +486,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-event.08-explicit-slot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-event.08-explicit-slot.json.snap index 8ca547dfa..8af962b7e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-event.08-explicit-slot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-event.08-explicit-slot.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T08:45:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-14T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -18,7 +30,7 @@ "action": { "name": "medication.intake.update", "entity_type": "intake_event", - "entity_id": "" + "entity_id": "" }, "meta": { "medication_id": "" @@ -32,7 +44,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -42,7 +54,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "Parity one-shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -76,7 +88,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Parity tablet", "dose": "10mg", "treatmentClass": "GENERIC", @@ -171,7 +183,7 @@ "intakes": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-10T09:00:00.000Z", "takenAt": "2026-10-10T09:30:00.000Z", @@ -192,7 +204,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:20:00.000Z", @@ -234,7 +246,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:05:00.000Z", @@ -255,7 +267,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -276,7 +288,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T08:40:00.000Z", @@ -297,7 +309,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -318,7 +330,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:15:00.000Z", @@ -339,7 +351,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T08:10:00.000Z", @@ -360,7 +372,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T08:45:00.000Z", @@ -380,8 +392,8 @@ "deletedAt": null }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", @@ -405,7 +417,7 @@ "inventoryItems": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "state": "IN_USE", "containerType": "OTHER", @@ -429,7 +441,7 @@ "audit": [ { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -447,10 +459,10 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "eventId": "", + "eventId": "", "medicationId": "", "fields": [ "scheduledFor", @@ -465,7 +477,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -481,7 +493,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -497,7 +509,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", @@ -513,7 +525,7 @@ }, { "action": "medication.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "eventId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap index d6d98517a..3fa6e304b 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-14T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": "ABDOMEN_LEFT", + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -19,13 +31,13 @@ "name": "medications.intake.update" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, - "intakeId": "", + "intakeId": "", "status": "taken" }, "warnings": null, @@ -37,7 +49,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -45,7 +57,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -53,9 +65,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -64,8 +76,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "badgeCount": 2 } @@ -76,7 +88,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "Daily pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -113,7 +125,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -147,7 +159,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Three times", "dose": "10mg", "treatmentClass": "GENERIC", @@ -180,8 +192,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -259,7 +271,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -339,7 +351,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -364,8 +376,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-09T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -385,8 +397,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-10T08:00:00.000Z", "takenAt": "2026-10-10T08:05:00.000Z", "skipped": false, @@ -406,8 +418,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -427,8 +439,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:10:00.000Z", "skipped": false, @@ -448,7 +460,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T07:00:00.000Z", "takenAt": "2026-10-13T07:05:00.000Z", @@ -469,8 +481,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": null, "skipped": false, @@ -490,7 +502,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T07:00:00.000Z", "takenAt": null, @@ -510,9 +522,9 @@ "deletedAt": null }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -532,8 +544,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", "skipped": false, @@ -548,7 +560,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -558,7 +570,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T06:00:00.000Z", "takenAt": null, @@ -579,8 +591,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -595,7 +607,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -605,7 +617,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": null, @@ -626,7 +638,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T10:00:00.000Z", "takenAt": "2026-10-15T10:05:00.000Z", @@ -647,7 +659,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T13:00:00.000Z", "takenAt": null, @@ -668,8 +680,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": false, @@ -691,9 +703,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -716,7 +728,7 @@ "audit": [ { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "intakeId": "", @@ -726,10 +738,10 @@ }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "intakeId": "", + "intakeId": "", "status": "taken" }, "ipAddress": null diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap index 666d31663..3b10a3bff 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T11:00:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-13T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -19,13 +31,13 @@ "name": "medications.intake.update" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, - "intakeId": "", + "intakeId": "", "status": "taken" }, "warnings": null, @@ -37,7 +49,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -45,7 +57,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -53,9 +65,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -64,8 +76,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "badgeCount": 2 } @@ -76,7 +88,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "Daily pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -113,7 +125,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -147,7 +159,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Three times", "dose": "10mg", "treatmentClass": "GENERIC", @@ -180,8 +192,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -259,7 +271,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -339,7 +351,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -364,8 +376,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-09T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -385,8 +397,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-10T08:00:00.000Z", "takenAt": "2026-10-10T08:05:00.000Z", "skipped": false, @@ -406,8 +418,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -427,8 +439,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:10:00.000Z", "skipped": false, @@ -448,7 +460,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T07:00:00.000Z", "takenAt": "2026-10-13T07:05:00.000Z", @@ -469,8 +481,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T11:00:00.000Z", "skipped": false, @@ -485,7 +497,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -494,9 +506,9 @@ "deletedAt": null }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": null, "skipped": false, @@ -516,7 +528,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T07:00:00.000Z", "takenAt": null, @@ -537,8 +549,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -558,8 +570,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", "skipped": false, @@ -574,7 +586,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -584,7 +596,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T06:00:00.000Z", "takenAt": null, @@ -605,8 +617,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -621,7 +633,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -631,7 +643,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": null, @@ -652,7 +664,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T10:00:00.000Z", "takenAt": "2026-10-15T10:05:00.000Z", @@ -673,7 +685,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T13:00:00.000Z", "takenAt": null, @@ -694,8 +706,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": false, @@ -717,9 +729,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -742,7 +754,7 @@ "audit": [ { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "intakeId": "", @@ -752,7 +764,7 @@ }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "intakeId": "", @@ -762,10 +774,10 @@ }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "intakeId": "", + "intakeId": "", "status": "taken" }, "ipAddress": null diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap index bd0c5ac92..a7a6cb365 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap @@ -3,12 +3,29 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-15T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "REMINDER", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": [ + { + "units": 1, + "itemId": "" + } + ], + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -19,7 +36,7 @@ "name": "medications.intake.update" }, "meta": { - "intakeId": "", + "intakeId": "", "status": "taken" }, "warnings": null, @@ -31,7 +48,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -39,7 +56,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -47,9 +64,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -58,8 +75,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "badgeCount": 2 } @@ -69,8 +86,8 @@ "stored": { "medications": [ { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Daily pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -106,8 +123,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -140,8 +157,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Three times", "dose": "10mg", "treatmentClass": "GENERIC", @@ -174,8 +191,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -212,8 +229,8 @@ ], "schedules": [ { - "id": "", - "medicationId": "", + "id": "", + "medicationId": "", "windowStart": "06:00", "windowEnd": "07:00", "label": null, @@ -232,8 +249,8 @@ "doseWindows": null }, { - "id": "", - "medicationId": "", + "id": "", + "medicationId": "", "windowStart": "07:00", "windowEnd": "08:00", "label": null, @@ -252,8 +269,8 @@ "doseWindows": null }, { - "id": "", - "medicationId": "", + "id": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -272,8 +289,8 @@ "doseWindows": null }, { - "id": "", - "medicationId": "", + "id": "", + "medicationId": "", "windowStart": "09:00", "windowEnd": "11:00", "label": null, @@ -292,8 +309,8 @@ "doseWindows": null }, { - "id": "", - "medicationId": "", + "id": "", + "medicationId": "", "windowStart": "10:00", "windowEnd": "11:00", "label": null, @@ -312,8 +329,8 @@ "doseWindows": null }, { - "id": "", - "medicationId": "", + "id": "", + "medicationId": "", "windowStart": "13:00", "windowEnd": "14:00", "label": null, @@ -332,8 +349,8 @@ "doseWindows": null }, { - "id": "", - "medicationId": "", + "id": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,9 +374,9 @@ "courses": [], "intakes": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-09T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -378,9 +395,9 @@ "deletedAt": "2026-10-09T09:00:00.000Z" }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-10T08:00:00.000Z", "takenAt": "2026-10-10T08:05:00.000Z", "skipped": false, @@ -399,9 +416,9 @@ "deletedAt": null }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -420,9 +437,9 @@ "deletedAt": null }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -441,9 +458,9 @@ "deletedAt": null }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T07:00:00.000Z", "takenAt": "2026-10-13T07:20:00.000Z", "skipped": false, @@ -462,9 +479,9 @@ "deletedAt": null }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T11:00:00.000Z", "skipped": false, @@ -479,7 +496,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -489,8 +506,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": null, "skipped": false, @@ -510,8 +527,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T07:00:00.000Z", "takenAt": null, "skipped": false, @@ -531,8 +548,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -552,8 +569,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:30:00.000Z", "skipped": false, @@ -568,7 +585,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -578,8 +595,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T06:00:00.000Z", "takenAt": null, "skipped": false, @@ -599,8 +616,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "", "skipped": false, @@ -615,7 +632,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -625,8 +642,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T09:15:00.000Z", "skipped": false, @@ -646,8 +663,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T10:00:00.000Z", "takenAt": "2026-10-15T10:05:00.000Z", "skipped": false, @@ -667,8 +684,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T13:00:00.000Z", "takenAt": null, "skipped": true, @@ -687,9 +704,9 @@ "deletedAt": null }, { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": false, @@ -711,9 +728,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -736,47 +753,47 @@ "audit": [ { "action": "medications.intake.update", - "userId": "", - "actorUserId": "", + "userId": "", + "actorUserId": "", "details": { - "intakeId": "", + "intakeId": "", "status": "taken" }, "ipAddress": null }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "intakeId": "", + "intakeId": "", "status": "skipped" }, "ipAddress": null }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "intakeId": "", + "intakeId": "", "status": "snoozed" }, "ipAddress": null }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "intakeId": "", + "intakeId": "", "status": "snoozed" }, "ipAddress": null }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "intakeId": "", @@ -786,7 +803,7 @@ }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "intakeId": "", @@ -796,7 +813,7 @@ }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "intakeId": "", @@ -806,17 +823,17 @@ }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "intakeId": "", + "intakeId": "", "status": "taken" }, "ipAddress": null }, { "action": "medications.intake.update", - "userId": "", + "userId": "", "actorUserId": null, "details": { "intakeId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap index b77e18c79..beb3a4a7a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-15T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "REMINDER", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +33,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 1, "skipped": false @@ -38,7 +50,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -46,7 +58,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -54,9 +66,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -65,8 +77,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "badgeCount": 2 } @@ -77,7 +89,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -111,7 +123,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -145,7 +157,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -178,8 +190,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -272,7 +284,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -332,7 +344,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,8 +369,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -378,8 +390,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -399,8 +411,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -420,8 +432,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -436,7 +448,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -448,9 +460,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -473,10 +485,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap index ad845eda4..3187012c4 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, - "idempotencyKey": null, - "scheduledFor": "2026-10-15T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,7 +33,7 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "skipped": true }, "warnings": null, @@ -33,7 +45,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -41,7 +53,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -49,9 +61,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "skipped" } ] @@ -60,8 +72,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "badgeCount": 2 } @@ -72,7 +84,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -106,7 +118,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -140,7 +152,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -173,8 +185,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -208,7 +220,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -267,7 +279,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -327,7 +339,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -352,8 +364,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -373,8 +385,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -394,8 +406,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -415,8 +427,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -441,7 +453,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": "2026-10-15T11:00:00.000Z", @@ -462,8 +474,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -486,8 +498,8 @@ "inventoryItems": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -510,10 +522,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -521,7 +533,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -532,10 +544,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap index e7d80ad2d..4a8993e7b 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap @@ -3,12 +3,29 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, - "idempotencyKey": null, - "scheduledFor": "2026-10-15T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "REMINDER", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": [ + { + "units": 1, + "itemId": "" + } + ], + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +38,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "skipped": true }, @@ -37,7 +54,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -45,7 +62,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -53,9 +70,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "skipped" } ] @@ -64,8 +81,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "badgeCount": 2 } @@ -76,7 +93,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -110,7 +127,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -144,7 +161,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -177,8 +194,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -212,7 +229,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -271,7 +288,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -331,7 +348,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -356,8 +373,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -377,8 +394,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -398,8 +415,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -419,8 +436,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -440,7 +457,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": "2026-10-15T11:00:00.000Z", @@ -461,8 +478,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -484,9 +501,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -509,10 +526,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -520,7 +537,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -531,10 +548,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -542,10 +559,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap index ea57265ca..b10338d3a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, "skipped": true, - "idempotencyKey": null, - "scheduledFor": "2026-10-15T11:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": [], + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,7 +33,7 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "skipped": true }, "warnings": null, @@ -33,7 +45,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -41,7 +53,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -49,9 +61,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "skipped" } ] @@ -60,8 +72,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "badgeCount": 2 } @@ -71,8 +83,8 @@ "stored": { "medications": [ { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -106,7 +118,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -140,7 +152,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -174,7 +186,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -208,7 +220,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -352,7 +364,7 @@ "intakes": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, @@ -373,7 +385,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": null, @@ -394,7 +406,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", @@ -415,7 +427,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, @@ -436,8 +448,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, "skipped": true, @@ -457,7 +469,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, @@ -481,7 +493,7 @@ "inventoryItems": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "state": "IN_USE", "containerType": "OTHER", @@ -505,7 +517,7 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -516,10 +528,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -527,7 +539,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -538,7 +550,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -549,10 +561,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -560,10 +572,10 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { - "medicationId": "", + "medicationId": "", "reason": "resolved_event" }, "ipAddress": null diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap index b2b4705c1..56d4d628a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-14T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "USER_PIN", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +33,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, "skipped": false @@ -38,7 +50,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -46,7 +58,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -54,9 +66,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -65,8 +77,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "badgeCount": 2 } @@ -77,7 +89,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -111,7 +123,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -145,7 +157,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -178,8 +190,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -272,7 +284,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -332,7 +344,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,8 +369,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -378,8 +390,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -399,8 +411,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -415,7 +427,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -425,8 +437,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -446,8 +458,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -467,7 +479,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -488,8 +500,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -511,9 +523,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -536,10 +548,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -547,7 +559,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -558,10 +570,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -569,10 +581,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -580,10 +592,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -591,7 +603,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -602,7 +614,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap index ea65b8d16..00581cc79 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-13T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "USER_PIN", "source": "REMINDER", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +33,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, "skipped": false @@ -38,7 +50,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -46,7 +58,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -54,9 +66,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -65,8 +77,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "badgeCount": 2 } @@ -77,7 +89,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -111,7 +123,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -145,7 +157,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -178,8 +190,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -272,7 +284,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -332,7 +344,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,8 +369,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -378,8 +390,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, @@ -394,7 +406,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -404,8 +416,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -420,7 +432,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -430,8 +442,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -451,8 +463,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -472,7 +484,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -493,8 +505,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -516,9 +528,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -541,10 +553,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -552,7 +564,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -563,10 +575,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -574,10 +586,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -585,10 +597,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -596,10 +608,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -607,7 +619,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -618,7 +630,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap index a5b9e6892..f97aba489 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", "skipped": false, - "idempotencyKey": "dose-key-0001", - "scheduledFor": "2026-10-13T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": "dose-key-0001", + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +33,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, "skipped": false @@ -38,7 +50,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -46,7 +58,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -54,9 +66,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -65,8 +77,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "badgeCount": 2 } @@ -77,7 +89,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -111,7 +123,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -145,7 +157,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -178,8 +190,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -272,7 +284,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -332,7 +344,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,8 +369,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -378,8 +390,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, @@ -394,7 +406,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -404,8 +416,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", "skipped": false, @@ -420,7 +432,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -430,8 +442,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -446,7 +458,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -456,8 +468,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -477,8 +489,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -498,7 +510,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -519,8 +531,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -542,9 +554,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -567,10 +579,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -578,7 +590,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -589,10 +601,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -600,10 +612,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -611,10 +623,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -622,10 +634,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -633,10 +645,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -644,7 +656,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -655,7 +667,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap index f4e433b3e..15d1bf5cc 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-12T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +33,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, "skipped": false @@ -38,7 +50,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -46,7 +58,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -54,9 +66,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -65,8 +77,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "badgeCount": 2 } @@ -77,7 +89,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -111,7 +123,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -145,7 +157,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -178,8 +190,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -272,7 +284,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -332,7 +344,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,8 +369,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", "skipped": false, @@ -373,7 +385,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -383,8 +395,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -404,8 +416,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, @@ -420,7 +432,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -430,8 +442,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", "skipped": false, @@ -446,7 +458,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -456,8 +468,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -472,7 +484,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -482,8 +494,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -503,8 +515,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -524,7 +536,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -545,8 +557,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -568,9 +580,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -593,10 +605,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -604,7 +616,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -615,10 +627,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -626,10 +638,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -637,10 +649,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -648,10 +660,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -659,10 +671,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -670,10 +682,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -681,7 +693,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -692,7 +704,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -703,7 +715,7 @@ ], "idempotency": [ { - "userId": "", + "userId": "", "key": "", "method": "POST", "path": "/api/medications//intake", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.07b-header-key-replay.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.07b-header-key-replay.json.snap index e66eeed11..a2bab3d76 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.07b-header-key-replay.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.07b-header-key-replay.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-12T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -30,8 +42,8 @@ "stored": { "medications": [ { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -64,8 +76,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -98,8 +110,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -132,8 +144,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -167,7 +179,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -206,7 +218,7 @@ "schedules": [ { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "07:00", "windowEnd": "08:00", "label": null, @@ -226,7 +238,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -266,7 +278,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "09:00", "windowEnd": "11:00", "label": null, @@ -286,7 +298,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -311,8 +323,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", "skipped": false, @@ -337,8 +349,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -358,8 +370,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, @@ -384,8 +396,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", "skipped": false, @@ -410,8 +422,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -436,8 +448,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -457,8 +469,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -478,8 +490,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, "skipped": true, @@ -499,8 +511,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -523,8 +535,8 @@ "inventoryItems": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -547,10 +559,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -558,10 +570,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -569,10 +581,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -580,10 +592,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -591,10 +603,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -602,10 +614,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -613,10 +625,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -624,10 +636,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -635,10 +647,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -646,10 +658,10 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { - "medicationId": "", + "medicationId": "", "reason": "resolved_event" }, "ipAddress": null @@ -657,7 +669,7 @@ ], "idempotency": [ { - "userId": "", + "userId": "", "key": "", "method": "POST", "path": "/api/medications//intake", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap index fdc949900..b60ae002d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:10:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-11T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "API", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +33,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, "skipped": false @@ -38,7 +50,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -46,7 +58,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -54,9 +66,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -65,8 +77,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "badgeCount": 2 } @@ -77,7 +89,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -111,7 +123,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -145,7 +157,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -178,8 +190,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -272,7 +284,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -332,7 +344,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,8 +369,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:10:00.000Z", "skipped": false, @@ -373,7 +385,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -383,8 +395,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", "skipped": false, @@ -399,7 +411,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -409,8 +421,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -430,8 +442,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, @@ -446,7 +458,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -456,8 +468,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", "skipped": false, @@ -472,7 +484,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -482,8 +494,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -498,7 +510,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -508,8 +520,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -529,8 +541,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -550,7 +562,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -571,8 +583,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -594,9 +606,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -619,10 +631,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -630,7 +642,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -641,10 +653,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -652,10 +664,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -663,10 +675,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -674,10 +686,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -685,10 +697,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -696,10 +708,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -707,10 +719,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -718,7 +730,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -729,7 +741,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -740,7 +752,7 @@ ], "idempotency": [ { - "userId": "", + "userId": "", "key": "", "method": "POST", "path": "/api/medications//intake", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap index 3b22d87c1..6794d89eb 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": null, "skipped": true, - "idempotencyKey": null, - "scheduledFor": "2026-10-14T20:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,7 +33,7 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "skipped": true }, "warnings": null, @@ -33,7 +45,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -41,7 +53,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -49,9 +61,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "skipped" } ] @@ -60,8 +72,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "badgeCount": 2 } @@ -72,7 +84,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -106,7 +118,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -140,7 +152,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -173,8 +185,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -208,7 +220,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -267,7 +279,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -327,7 +339,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -352,8 +364,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:10:00.000Z", "skipped": false, @@ -378,8 +390,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", "skipped": false, @@ -404,8 +416,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -425,8 +437,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, @@ -451,8 +463,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", "skipped": false, @@ -477,8 +489,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -503,8 +515,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -524,8 +536,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -545,7 +557,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -566,8 +578,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -590,8 +602,8 @@ "inventoryItems": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -614,10 +626,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -625,7 +637,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -636,10 +648,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -647,10 +659,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -658,10 +670,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -669,10 +681,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -680,10 +692,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -691,10 +703,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -702,10 +714,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -713,7 +725,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -724,10 +736,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -735,7 +747,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -745,10 +757,10 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { - "medicationId": "", + "medicationId": "", "reason": "resolved_event" }, "ipAddress": null @@ -756,7 +768,7 @@ ], "idempotency": [ { - "userId": "", + "userId": "", "key": "", "method": "POST", "path": "/api/medications//intake", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap index 30e91414f..c4053484a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-11T08:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,10 +33,10 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 1, "item_ids": [ - "" + "" ], "auto_opened": 0, "skipped": false @@ -38,7 +50,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -46,7 +58,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -54,9 +66,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", + "ownerId": "", "actorId": "", - "medicationId": "", + "medicationId": "", "status": "taken" } ] @@ -65,8 +77,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "badgeCount": 2 } @@ -77,7 +89,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -111,7 +123,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -145,7 +157,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -178,8 +190,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -213,7 +225,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -272,7 +284,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -332,7 +344,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -357,8 +369,8 @@ "intakes": [ { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", "skipped": false, @@ -373,7 +385,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -383,8 +395,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:10:00.000Z", "skipped": false, @@ -399,7 +411,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -409,8 +421,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", "skipped": false, @@ -425,7 +437,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -435,8 +447,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -456,8 +468,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", "skipped": false, @@ -472,7 +484,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -482,8 +494,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", "skipped": false, @@ -498,7 +510,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -508,8 +520,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", "skipped": false, @@ -524,7 +536,7 @@ "inventoryConsumption": [ { "units": 1, - "itemId": "" + "itemId": "" } ], "externalId": null, @@ -534,8 +546,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -555,8 +567,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, "skipped": true, @@ -576,7 +588,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -597,8 +609,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -620,9 +632,9 @@ "doseChanges": [], "inventoryItems": [ { - "id": "", - "userId": "", - "medicationId": "", + "id": "", + "userId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", @@ -645,10 +657,10 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": "", "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -656,10 +668,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -667,7 +679,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -678,10 +690,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -689,10 +701,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -700,10 +712,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -711,10 +723,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -722,10 +734,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -733,10 +745,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -744,10 +756,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -755,7 +767,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -766,10 +778,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": true }, @@ -777,7 +789,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -787,10 +799,10 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { - "medicationId": "", + "medicationId": "", "reason": "resolved_event" }, "ipAddress": null @@ -798,7 +810,7 @@ ], "idempotency": [ { - "userId": "", + "userId": "", "key": "", "method": "POST", "path": "/api/medications//intake", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap index 8c3e32ca8..6ac1a2a5d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T09:10:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-15T09:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": "ABDOMEN_LEFT", + "doseTaken": "0.5 pen", + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -21,7 +33,7 @@ "entity_id": "" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 0, "item_ids": [], "auto_opened": 0, @@ -36,7 +48,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -44,7 +56,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -52,9 +64,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -63,8 +75,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "badgeCount": 2 } @@ -75,7 +87,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -109,7 +121,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -143,7 +155,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -177,7 +189,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -210,8 +222,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -290,7 +302,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "09:00", "windowEnd": "11:00", "label": null, @@ -355,7 +367,7 @@ "intakes": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", @@ -381,7 +393,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:10:00.000Z", @@ -407,7 +419,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", @@ -433,7 +445,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, @@ -454,7 +466,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", @@ -480,7 +492,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", @@ -506,7 +518,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", @@ -532,7 +544,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": null, @@ -553,7 +565,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, @@ -574,8 +586,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T09:10:00.000Z", "skipped": false, @@ -595,7 +607,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -616,7 +628,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, @@ -640,7 +652,7 @@ "inventoryItems": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "state": "IN_USE", "containerType": "OTHER", @@ -664,7 +676,7 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -675,7 +687,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -686,7 +698,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -697,7 +709,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -708,7 +720,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -719,7 +731,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -730,7 +742,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -741,7 +753,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -752,10 +764,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -763,7 +775,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -774,7 +786,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -785,7 +797,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -796,7 +808,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -807,7 +819,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -817,7 +829,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -828,7 +840,7 @@ ], "idempotency": [ { - "userId": "", + "userId": "", "key": "", "method": "POST", "path": "/api/medications//intake", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap index 07271f7b7..034101fed 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap @@ -3,12 +3,24 @@ "body": { "data": { "id": "", + "userId": "", + "medicationId": "", + "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T10:00:00.000Z", "skipped": false, - "idempotencyKey": null, - "scheduledFor": "2026-10-15T09:00:00.000Z", + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", "source": "WEB", - "createdAt": "" + "idempotencyKey": null, + "createdAt": "", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "", + "syncVersion": "", + "deletedAt": null }, "error": null }, @@ -19,7 +31,7 @@ "name": "medication.oneShot.reconciled" }, "meta": { - "medication_id": "", + "medication_id": "", "units": 0, "item_ids": [], "auto_opened": 0, @@ -35,7 +47,7 @@ "effect": "queueMedicationIntakeSync", "args": [ { - "userId": "", + "userId": "", "originDeviceToken": null } ] @@ -43,7 +55,7 @@ { "effect": "countOutstandingDosesToday", "args": [ - "", + "", "UTC" ] }, @@ -51,9 +63,9 @@ "effect": "notifyDelegatedIntake", "args": [ { - "ownerId": "", - "actorId": "", - "medicationId": "", + "ownerId": "", + "actorId": "", + "medicationId": "", "status": "taken" } ] @@ -62,8 +74,8 @@ "effect": "dispatchMedicationIntakeWebClear", "args": [ { - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "badgeCount": 2 } @@ -74,7 +86,7 @@ "medications": [ { "id": "", - "userId": "", + "userId": "", "name": "As needed", "dose": "10mg", "treatmentClass": "GENERIC", @@ -108,7 +120,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Record only", "dose": "10mg", "treatmentClass": "GENERIC", @@ -141,8 +153,8 @@ "updatedAt": "" }, { - "id": "", - "userId": "", + "id": "", + "userId": "", "name": "Single shot", "dose": "10mg", "treatmentClass": "GENERIC", @@ -176,7 +188,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Twice daily", "dose": "10mg", "treatmentClass": "GENERIC", @@ -210,7 +222,7 @@ }, { "id": "", - "userId": "", + "userId": "", "name": "Weekly pen", "dose": "10mg", "treatmentClass": "GENERIC", @@ -309,7 +321,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "09:00", "windowEnd": "11:00", "label": null, @@ -354,7 +366,7 @@ "intakes": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T08:00:00.000Z", "takenAt": "2026-10-11T08:20:00.000Z", @@ -380,7 +392,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-11T20:00:00.000Z", "takenAt": "2026-10-11T20:10:00.000Z", @@ -406,7 +418,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T08:00:00.000Z", "takenAt": "2026-10-12T08:15:00.000Z", @@ -432,7 +444,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-12T20:00:00.000Z", "takenAt": null, @@ -453,7 +465,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-15T11:30:00.000Z", @@ -479,7 +491,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-13T20:00:00.000Z", "takenAt": "2026-10-13T20:30:00.000Z", @@ -505,7 +517,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T08:00:00.000Z", "takenAt": "2026-10-14T11:00:00.000Z", @@ -531,7 +543,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": null, @@ -552,7 +564,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": null, @@ -573,7 +585,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T09:10:00.000Z", @@ -594,8 +606,8 @@ }, { "id": "", - "userId": "", - "medicationId": "", + "userId": "", + "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T10:00:00.000Z", "skipped": false, @@ -615,7 +627,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T11:00:00.000Z", "takenAt": null, @@ -636,7 +648,7 @@ }, { "id": "", - "userId": "", + "userId": "", "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, @@ -660,7 +672,7 @@ "inventoryItems": [ { "id": "", - "userId": "", + "userId": "", "medicationId": "", "state": "IN_USE", "containerType": "OTHER", @@ -684,7 +696,7 @@ "audit": [ { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -695,7 +707,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -706,7 +718,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -717,7 +729,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -728,7 +740,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -739,7 +751,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -750,7 +762,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -761,7 +773,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -772,7 +784,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -783,10 +795,10 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "eventId": "", "skipped": false }, @@ -794,7 +806,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -805,7 +817,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -816,7 +828,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -827,7 +839,7 @@ }, { "action": "medication.intake", - "userId": "", + "userId": "", "actorUserId": null, "details": { "medicationId": "", @@ -838,17 +850,17 @@ }, { "action": "medication.oneShot.reconciled", - "userId": "", + "userId": "", "actorUserId": null, "details": { - "medicationId": "", + "medicationId": "", "action": "deactivate" }, "ipAddress": null }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -858,7 +870,7 @@ }, { "action": "medications.intake.update.refused", - "userId": "", + "userId": "", "actorUserId": "", "details": { "medicationId": "", @@ -869,7 +881,7 @@ ], "idempotency": [ { - "userId": "", + "userId": "", "key": "", "method": "POST", "path": "/api/medications//intake", diff --git a/tests/integration/routes/parity/__snapshots__/medications.11-plain.json.snap b/tests/integration/routes/parity/__snapshots__/medications.11-plain.json.snap index 8882ea379..42f544ad5 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.11-plain.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.11-plain.json.snap @@ -62,7 +62,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T05:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.12-full.json.snap b/tests/integration/routes/parity/__snapshots__/medications.12-full.json.snap index 24775d467..9a4f14354 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.12-full.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.12-full.json.snap @@ -80,7 +80,17 @@ ], "courseCount": 1, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-12T16:30:00.000Z", + "remindersActive": false, + "nextDueOverdue": true, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.13a-as-needed.json.snap b/tests/integration/routes/parity/__snapshots__/medications.13a-as-needed.json.snap index c23aca305..f3a3616bd 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.13a-as-needed.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.13a-as-needed.json.snap @@ -40,7 +40,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": null, + "remindersActive": false, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.13b-one-shot.json.snap b/tests/integration/routes/parity/__snapshots__/medications.13b-one-shot.json.snap index a43296040..e90f7436b 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.13b-one-shot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.13b-one-shot.json.snap @@ -71,7 +71,17 @@ ], "courseCount": 1, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-20T07:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "UPCOMING", + "intakeActionable": false, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap b/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap index 81a5f1d0c..70bc8174e 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.13c-prn-and-legacy-days.json.snap @@ -105,7 +105,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 }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.13d-ended-window.json.snap b/tests/integration/routes/parity/__snapshots__/medications.13d-ended-window.json.snap index b12f37c1b..6c2a8d479 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.13d-ended-window.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.13d-ended-window.json.snap @@ -71,7 +71,17 @@ ], "courseCount": 1, "previousCourseEndedOn": "2026-10-05", - "canStartCourse": true + "canStartCourse": true, + "nextDueAt": null, + "remindersActive": false, + "nextDueOverdue": false, + "courseStatus": "ENDED", + "intakeActionable": false, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.15a-mirror.json.snap b/tests/integration/routes/parity/__snapshots__/medications.15a-mirror.json.snap index 302f69f2d..5b84cf296 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.15a-mirror.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.15a-mirror.json.snap @@ -62,7 +62,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T06:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.15b-mirror-replay.json.snap b/tests/integration/routes/parity/__snapshots__/medications.15b-mirror-replay.json.snap index 25c308749..ca1c2c536 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.15b-mirror-replay.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.15b-mirror-replay.json.snap @@ -58,7 +58,21 @@ } ], "category": "OTHER", - "categoryLabel": null + "categoryLabel": null, + "courses": [], + "courseCount": 0, + "previousCourseEndedOn": null, + "canStartCourse": false, + "nextDueAt": "2026-10-16T06:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.17a-idempotent-first.json.snap b/tests/integration/routes/parity/__snapshots__/medications.17a-idempotent-first.json.snap index d6d186d9a..075912d7f 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.17a-idempotent-first.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.17a-idempotent-first.json.snap @@ -62,7 +62,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T06:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.17b-idempotent-replay.json.snap b/tests/integration/routes/parity/__snapshots__/medications.17b-idempotent-replay.json.snap index 478f992f5..b2041d2c4 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.17b-idempotent-replay.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.17b-idempotent-replay.json.snap @@ -62,7 +62,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T06:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/__snapshots__/medications.18a-writer.json.snap b/tests/integration/routes/parity/__snapshots__/medications.18a-writer.json.snap index ca74c6b43..524d5101f 100644 --- a/tests/integration/routes/parity/__snapshots__/medications.18a-writer.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medications.18a-writer.json.snap @@ -62,7 +62,17 @@ "courses": [], "courseCount": 0, "previousCourseEndedOn": null, - "canStartCourse": false + "canStartCourse": false, + "nextDueAt": "2026-10-16T06:00:00.000Z", + "remindersActive": true, + "nextDueOverdue": false, + "courseStatus": "CURRENT", + "intakeActionable": true, + "stockUnitsRemaining": null, + "stockDosesRemaining": null, + "runwayDays": null, + "ingredients": [], + "related": [] }, "error": null }, diff --git a/tests/integration/routes/parity/medication-detail.parity.test.ts b/tests/integration/routes/parity/medication-detail.parity.test.ts index 64b13ac07..8ca3932e7 100644 --- a/tests/integration/routes/parity/medication-detail.parity.test.ts +++ b/tests/integration/routes/parity/medication-detail.parity.test.ts @@ -73,15 +73,6 @@ vi.mock( const prisma = getPrismaClient(); const NOW = new Date("2026-10-15T12:00:00.000Z"); const ROUTE = "/api/medications/{id}"; -/** - * The edit answers without the read-model fields (reminder state, stock and - * runway, composition, links) the registered medication resource declares. - * The parity snapshot pins that answer as it is; the contract check is the - * route's own tests' business. - */ -const EDIT_ANSWER = { - skip: "the edit answers without the read-model fields of the resource", -}; const CUSTOM_KEY = "custom:5b1f7c2e-4d3a-4b8e-9c61-2a7d0e9f4c13"; let ownerId: string; @@ -127,7 +118,6 @@ async function put( body, auth: caller.auth ?? owner, headers: caller.headers, - contract: EDIT_ANSWER, }, userIds, now: NOW, diff --git a/tests/integration/routes/parity/medication-intake-event.parity.test.ts b/tests/integration/routes/parity/medication-intake-event.parity.test.ts index 415422a60..acfbe8d4b 100644 --- a/tests/integration/routes/parity/medication-intake-event.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-event.parity.test.ts @@ -59,15 +59,6 @@ vi.mock( const prisma = getPrismaClient(); const NOW = new Date("2026-10-15T12:00:00.000Z"); const ROUTE = "/api/medications/{id}/intake/{eventId}"; -/** - * A correction that moves the dose answers with the row the shared slot write - * returns, which carries fewer fields than the registered event resource. The - * parity snapshot pins that answer as it is; the contract check is the - * route's own tests' business. - */ -const SLOT_MOVE_ANSWER = { - skip: "a slot move answers with the narrower slot-write row", -}; let ownerId: string; let userIds: string[]; @@ -86,7 +77,6 @@ async function put( auth?: RouteAuth; headers?: Record; medicationId?: string; - contract?: { skip: string }; } = {}, ) { return parityStep({ @@ -99,7 +89,6 @@ async function put( body, auth: options.auth ?? owner, headers: options.headers, - contract: options.contract, }, userIds, now: NOW, @@ -252,22 +241,16 @@ describe("PUT /api/medications/{id}/intake/{eventId} parity", () => { }); it("03 a take corrected into the other window, onto a skipped slot", async () => { - const res = await put( - "03-move-by-band", - events.morningTaken, - { takenAt: "2026-10-14T20:30:00.000Z" }, - { contract: SLOT_MOVE_ANSWER }, - ); + const res = await put("03-move-by-band", events.morningTaken, { + takenAt: "2026-10-14T20:30:00.000Z", + }); events.moved = (res.data as { id: string }).id; }); it("04 a legacy take pinned onto a free slot", async () => { - const res = await put( - "04-pin-free-slot", - events.legacyTaken, - { forceSlotInstant: "2026-10-12T20:00:00.000Z" }, - { contract: SLOT_MOVE_ANSWER }, - ); + const res = await put("04-pin-free-slot", events.legacyTaken, { + forceSlotInstant: "2026-10-12T20:00:00.000Z", + }); events.pinned = (res.data as { id: string }).id; }); @@ -284,25 +267,17 @@ describe("PUT /api/medications/{id}/intake/{eventId} parity", () => { }); it("07 an unpin", async () => { - const res = await put( - "07-unpin", - events.pinned, - { forceSlotInstant: null }, - { contract: SLOT_MOVE_ANSWER }, - ); + const res = await put("07-unpin", events.pinned, { + forceSlotInstant: null, + }); events.pinned = (res.data as { id: string }).id; }); it("08 an explicit slot in the body", async () => { - const res = await put( - "08-explicit-slot", - events.moved, - { - scheduledFor: "2026-10-14T08:00:00.000Z", - takenAt: "2026-10-14T08:45:00.000Z", - }, - { contract: SLOT_MOVE_ANSWER }, - ); + const res = await put("08-explicit-slot", events.moved, { + scheduledFor: "2026-10-14T08:00:00.000Z", + takenAt: "2026-10-14T08:45:00.000Z", + }); events.moved = (res.data as { id: string }).id; }); diff --git a/tests/integration/routes/parity/medication-intake-status.parity.test.ts b/tests/integration/routes/parity/medication-intake-status.parity.test.ts index b83135251..92f6c25bb 100644 --- a/tests/integration/routes/parity/medication-intake-status.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-status.parity.test.ts @@ -132,21 +132,7 @@ async function get( }); } -/** - * A take that re-binds to another slot answers with the row the shared slot - * write returns, which carries fewer fields than the registered event - * resource. The parity snapshot pins that answer as it is; the contract check - * is the route's own tests' business. - */ -const SLOT_MOVE_ANSWER = { - skip: "a slot move answers with the narrower slot-write row", -}; - -async function post( - name: string, - body: unknown, - caller: Caller & { contract?: { skip: string } } = {}, -) { +async function post(name: string, body: unknown, caller: Caller = {}) { await parityStep({ name: `medication-intake-status.${name}`, handler: POST, @@ -156,7 +142,6 @@ async function post( body, auth: caller.auth ?? owner, headers: caller.headers, - contract: caller.contract, }, userIds, now: NOW, @@ -362,28 +347,20 @@ describe("POST /api/medications/intake parity", () => { }); it("12 a take that re-binds to another slot", async () => { - await post( - "12-take-slot-move", - { - intakeId: ev.moveMe, - status: "taken", - takenAt: "2026-10-14T20:30:00.000Z", - }, - { contract: SLOT_MOVE_ANSWER }, - ); + await post("12-take-slot-move", { + intakeId: ev.moveMe, + status: "taken", + takenAt: "2026-10-14T20:30:00.000Z", + }); }); it("13 a pinned take, and a pin that is no slot", async () => { - await post( - "13a-pin", - { - intakeId: ev.pinMe, - status: "taken", - takenAt: "2026-10-13T11:00:00.000Z", - forceSlotInstant: "2026-10-13T08:00:00.000Z", - }, - { contract: SLOT_MOVE_ANSWER }, - ); + await post("13a-pin", { + intakeId: ev.pinMe, + status: "taken", + takenAt: "2026-10-13T11:00:00.000Z", + forceSlotInstant: "2026-10-13T08:00:00.000Z", + }); await post("13b-pin-not-a-slot", { intakeId: ev.snoozeMe, status: "taken", @@ -451,7 +428,7 @@ describe("POST /api/medications/intake parity", () => { await post( "20a-writer-take", { intakeId: ev.todayEvening, status: "taken" }, - { ...writer, contract: SLOT_MOVE_ANSWER }, + { ...writer }, ); await post( "20b-writer-snooze-refused", diff --git a/tests/integration/routes/parity/medication-intake.parity.test.ts b/tests/integration/routes/parity/medication-intake.parity.test.ts index 5d66bd899..e87cf639b 100644 --- a/tests/integration/routes/parity/medication-intake.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake.parity.test.ts @@ -108,15 +108,6 @@ vi.mock("@/lib/notifications/delegated-intake", async (importOriginal) => { const prisma = getPrismaClient(); const NOW = new Date("2026-10-15T12:00:00.000Z"); const ROUTE = "/api/medications/{id}/intake"; -/** - * A dose that converges onto a slot answers with the row the shared slot - * write returns, which carries fewer fields than the registered event - * resource. The parity snapshot pins that answer as it is; the contract check - * is the route's own tests' business. - */ -const DOSE_ANSWER = { - skip: "a slot write answers with the narrower slot-write row", -}; const REFUSED = "medications.intake.update.refused"; let ownerId: string; @@ -159,7 +150,6 @@ async function post( body, auth: options.auth ?? owner, headers: options.headers, - contract: DOSE_ANSWER, }, userIds, now: NOW, diff --git a/tests/integration/routes/parity/medications.parity.test.ts b/tests/integration/routes/parity/medications.parity.test.ts index 028bfffa6..0e3b15478 100644 --- a/tests/integration/routes/parity/medications.parity.test.ts +++ b/tests/integration/routes/parity/medications.parity.test.ts @@ -42,15 +42,6 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => { const prisma = getPrismaClient(); const NOW = new Date("2026-10-15T12:00:00.000Z"); const ROUTE = "/api/medications"; -/** - * The create answers without the read-model fields (reminder state, course - * status, stock and runway) the registered medication resource declares. The - * parity snapshot pins that answer as it is; the contract check is the - * route's own tests' business. - */ -const CREATE_ANSWER = { - skip: "the create answers without the read-model fields of the resource", -}; const CUSTOM_KEY = "custom:5b1f7c2e-4d3a-4b8e-9c61-2a7d0e9f4c13"; let ownerId: string; @@ -95,7 +86,6 @@ async function create( body, auth: options.auth ?? owner, headers: options.headers, - contract: CREATE_ANSWER, }, userIds: options.userIds ?? userIds, now: NOW, From fb9cd9b965505da30090302dacd9e63d0ac20736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 17:19:21 +0200 Subject: [PATCH 10/11] refactor(ai): move the stored-key opener and the Anthropic host check out of provider.ts Both are self-contained; provider.ts stays within its size baseline. --- src/app/api/admin/provider-health/route.ts | 2 +- src/lib/ai/anthropic-host.ts | 18 +++++++ src/lib/ai/provider.ts | 50 +++---------------- src/lib/ai/reasoning/controls.ts | 3 +- src/lib/ai/stored-key.ts | 21 ++++++++ .../background-reasoning-wire.test.ts | 1 - 6 files changed, 49 insertions(+), 46 deletions(-) create mode 100644 src/lib/ai/anthropic-host.ts create mode 100644 src/lib/ai/stored-key.ts diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 38a761efe..341164e1c 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.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 { diff --git a/src/lib/ai/anthropic-host.ts b/src/lib/ai/anthropic-host.ts new file mode 100644 index 000000000..6a9b04eed --- /dev/null +++ b/src/lib/ai/anthropic-host.ts @@ -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"); +} diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 5343559dc..d589685ce 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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"; @@ -34,7 +35,6 @@ import { } from "@/lib/sharing/provider-work-authority"; import { memoizePerRequest } from "@/lib/request-cache"; import { emitSignal } from "@/lib/logging/signal"; -import { getEvent } from "@/lib/logging/context"; import { acquireProviderTokenRefreshLock, PROVIDER_REFRESH_TRANSACTION_OPTIONS, @@ -371,22 +371,6 @@ function isOperatorOwned(row: { role: string } | null | undefined): boolean { return row?.role === "ADMIN"; } -/** - * 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. - */ -function openStoredKey(encrypted: string): string | null { - try { - return decrypt(encrypted); - } catch { - getEvent()?.addWarning("ai: a stored provider key could not be opened"); - return null; - } -} - /** * v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's * three dedicated columns. The signature names every column this provider is @@ -479,8 +463,9 @@ 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 = openStoredKey(row.aiOpenaiKeyEncrypted); + const apiKey = row.aiOpenaiKeyEncrypted + ? openStoredKey(row.aiOpenaiKeyEncrypted) + : null; if (apiKey === null) return null; return new OpenAIClient({ apiKey, @@ -496,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. @@ -1381,8 +1347,7 @@ async function resolveProviderForType( } case "openai": { const enc = ctx.userRow?.aiOpenaiKeyEncrypted; - if (!enc) return null; - const apiKey = openStoredKey(enc); + const apiKey = enc ? openStoredKey(enc) : null; if (apiKey === null) return null; return new OpenAIClient({ apiKey, @@ -1392,8 +1357,7 @@ async function resolveProviderForType( } case "anthropic": { const enc = ctx.userRow?.aiAnthropicKeyEncrypted; - if (!enc) return null; - const apiKey = openStoredKey(enc); + const apiKey = enc ? openStoredKey(enc) : null; if (apiKey === null) return null; return new AnthropicClient({ apiKey, diff --git a/src/lib/ai/reasoning/controls.ts b/src/lib/ai/reasoning/controls.ts index a34655774..624d7055f 100644 --- a/src/lib/ai/reasoning/controls.ts +++ b/src/lib/ai/reasoning/controls.ts @@ -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"; diff --git a/src/lib/ai/stored-key.ts b/src/lib/ai/stored-key.ts new file mode 100644 index 000000000..e4e6ad007 --- /dev/null +++ b/src/lib/ai/stored-key.ts @@ -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; + } +} diff --git a/src/lib/insights/__tests__/background-reasoning-wire.test.ts b/src/lib/insights/__tests__/background-reasoning-wire.test.ts index a135cbea1..aeb843364 100644 --- a/src/lib/insights/__tests__/background-reasoning-wire.test.ts +++ b/src/lib/insights/__tests__/background-reasoning-wire.test.ts @@ -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", () => ({ From a0dce10c1a436ce8501f0d874d0579ea365dbdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 17:19:21 +0200 Subject: [PATCH 11/11] fix(activity): word the refusals that now reach a delegate's feed The custom-metric create and update refusals and the medication layout refusal are filed through auditLog now, so a delegate's refused request reaches the owner's activity feed, where it read as the generic change. Each gets its own sentence in every bundle; the refusal sentences live in their own function. The audit write guard states its floor, and the schedule-reader guard names the medication create and edit, which answer through medicationDetail. --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/ko.json | 3 + messages/nl.json | 3 + messages/pl.json | 3 + messages/pt-BR.json | 3 + src/__tests__/audit-write-path-guard.test.ts | 2 + .../baselines/source-file-lines.json | 16 +--- .../baselines/source-function-lines.json | 10 +- .../medication-intake-tracking-guard.test.ts | 9 +- src/lib/record-activity/activity-verb.ts | 96 ++++++++++++------- 14 files changed, 106 insertions(+), 54 deletions(-) diff --git a/messages/de.json b/messages/de.json index ae2bbef23..52c9e2293 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 0c7e01d4f..00826c3db 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index c0eb52e19..ecc6d0e7c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 1ae591455..69fbcdfb8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 9e30bf5ca..211211b5a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 0197d663d..5069c1d45 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12719,6 +12719,9 @@ "rejectedDoseUpdate": "{name} 님이 복용 기록을 수정하려다 거절됐어요", "rejectedDoseImport": "{name} 님이 복용 기록을 불러오려다 거절됐어요", "rejectedTrackedValue": "{name} 님이 추적 값을 기록하려다 거절됐어요", + "rejectedTrackedValueTypeCreate": "{name} 님이 추적 값 종류를 추가하려다 거절됐어요", + "rejectedTrackedValueTypeUpdate": "{name} 님이 추적 값 종류를 수정하려다 거절됐어요", + "rejectedMedicationLayout": "{name} 님이 약 목록 순서를 바꾸려다 거절됐어요", "deniedDoseImport": "{name} 님이 시작한 복용 기록 불러오기를 대기열에 넣지 못했어요", "anamnesisFactCreate": "{name} 님이 건강 배경 항목을 추가했어요", "customMetricCreate": "{name} 님이 추적 값 종류를 추가했어요", diff --git a/messages/nl.json b/messages/nl.json index 86a5440ec..f091c3c1d 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 916c2239a..947424424 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index fcd90cced..277297c97 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/src/__tests__/audit-write-path-guard.test.ts b/src/__tests__/audit-write-path-guard.test.ts index 43f6d5bf9..32fda84fc 100644 --- a/src/__tests__/audit-write-path-guard.test.ts +++ b/src/__tests__/audit-write-path-guard.test.ts @@ -39,6 +39,8 @@ describe("audit rows go through auditLog()", () => { .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"); }); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 822bd738b..434124a03 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,16 +1,11 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 101, - "total": 116897, + "count": 91, + "total": 101342, "entries": { "app/medications/page-client.tsx": 839, - "app/page-client.tsx": 2324, - "components/admin/backups-section.tsx": 1462, - "components/charts/health-chart.tsx": 2517, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, - "components/documents/document-detail-sheet.tsx": 1304, - "components/documents/documents-view.tsx": 1223, "components/insights/coach-panel/chat-bubble.tsx": 888, "components/insights/coach-panel/coach-conversation.tsx": 1286, "components/insights/coach-panel/use-coach.ts": 1486, @@ -18,17 +13,12 @@ "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1086, "components/layout/sidebar-nav.tsx": 855, - "components/measurement-reminders/vorsorge-section.tsx": 1536, "components/measurements/measurement-form.tsx": 815, - "components/measurements/measurement-list.tsx": 2019, "components/medications/detail/medication-detail-tabs.tsx": 1160, - "components/medications/dose-history-ledger.tsx": 1052, "components/medications/medication-table.tsx": 853, "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, "components/records/life-circumstances-manager.tsx": 1080, - "components/settings/dashboard-layout-section.tsx": 1033, - "components/settings/managed-record-settings-section.tsx": 1071, "components/timeline/timeline-chart.tsx": 1174, "components/timeline/timeline-geometry.ts": 1395, "lib/ai/anthropic-client.ts": 804, @@ -92,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, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d7c1b5530..4dcbcf91c 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 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, diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index 0558a4887..d84b41aae 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -115,10 +115,17 @@ const MAY_READ_EVERY_ROW: Record = { "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": diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 1b37e5254..cbaae53b6 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -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; } }