diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index b36fd59eb..9021ada25 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -72,6 +72,8 @@ const SCOPED_DIRS = [ "lib/medications/read-medication.ts", "lib/medications/update-medication/", "lib/medications/delete-medication.ts", + "lib/medications/record-intake/", + "lib/medications/list-intake-events.ts", ]; /** Types a device writes at sampling rate rather than a few times a day. */ diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index 9313e5e3b..3df0ec79d 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -66,8 +66,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record = { "lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge", "lib/ai/coach/tools/availability.ts": "whether any intake history exists, and its date range", - "app/api/medications/[id]/intake/route.ts": - "one medication's recorded history, listed or written", + "lib/medications/list-intake-events.ts": + "one medication's recorded history, listed", "app/api/medications/[id]/intake/bulk-delete/route.ts": "deletes rows the person chose", "lib/medications/update-medication/write.ts": diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b7e69ff8b..d910c57a3 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -439,6 +439,9 @@ const HANDLER_BODY_MODULES: Record = { "lib/medications/update-medication/update-medication.ts", "lib/medications/delete-medication.ts", ], + "app/api/medications/[id]/intake/route.ts": [ + "lib/medications/record-intake/record-intake.ts", + ], "app/api/medications/[id]/intake/[eventId]/route.ts": [ "lib/medications/update-intake-event.ts", "lib/medications/delete-intake-event.ts", diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index b3b7fa966..7d8eebf6d 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -1,4 +1,3 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -18,32 +17,13 @@ import { unstableExternalIdMeta, unstableExternalIdShape, } from "@/lib/validations/external-id"; -import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; -import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; import { withIdempotency } from "@/lib/idempotency"; -import { - consumeForIntake, - restoreForIntake, -} from "@/lib/medications/inventory/consumption"; -import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { assertMedicationOwnership, assertMedicationTakesIntake, } from "@/lib/medications/route-guards"; -import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; -import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; -import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; -import { - applyCanonicalSlotWrite, - findPinConflict, - mayConvergeOntoSuppliedSlot, - resolveForcedSlotForWrite, - resolveSlotForWriteByBand, - resolveSlotInstantForWrite, -} from "@/lib/medications/scheduling/slot-upsert"; +import { recordIntake } from "@/lib/medications/record-intake/record-intake"; +import { listIntakeEvents } from "@/lib/medications/list-intake-events"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -53,25 +33,25 @@ export const POST = apiHandler( ); async function postIntake(request: NextRequest, { params }: RouteParams) { - // v1.36.x — a delegated write, the per-medication form of the same verb. + // A delegated write, the per-medication form of the same verb. // `user` is the record the dose belongs to; `actor` is whoever pressed the - // button, and is used for nothing but the owner's notification below. + // button, and is used for nothing but the owner's notification. const { user, actor, authMethod } = await requireRecordAuth( "write", "medications", ); - // v1.36.1 — the sibling route `POST /api/medications/intake` - // refuses a delegate changing a dose the owner already recorded. This route - // reaches the same rows through the slot upsert, so without the same rule it - // is simply the way round the other one: an explicit skip posted onto a - // taken slot flips the outcome and refunds the inventory the take consumed. - // The flag rides into `applyCanonicalSlotWrite`, which is where the existing - // row is already loaded, so the check adds no query and cannot race. + // The sibling route `POST /api/medications/intake` refuses a delegate + // changing a dose the owner already recorded. This route reaches the same + // rows through the slot upsert, so without the same rule it is simply the + // way round the other one: an explicit skip posted onto a taken slot flips + // the outcome and refunds the inventory the take consumed. The flag rides + // into `applyCanonicalSlotWrite`, which is where the existing row is + // already loaded, so the check adds no query and cannot race. const isDelegated = actor.id !== user.id; /** - * The refusal itself, shared by both upsert call sites so the two cannot + * The refusal itself, answered for both upsert call sites so the two cannot * drift apart. Files under the OWNER — `auditLog` stamps the actor from the * request's acting context — so a refused attempt reaches the activity panel * the same way the sibling route's does, under the same action name. @@ -92,7 +72,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { { errorCode: "sharing.not_permitted" }, ); } - // v1.32.8 (iOS #64) — write provenance derived from the transport, never + // Write provenance derived from the transport (iOS #64), never // client-asserted: a Bearer/native call stores `API`, a cookie/browser call // stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER // routes (the Telegram worker mints `REMINDER`, the bulk/import paths mint @@ -103,8 +83,8 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { const intakeSource = authMethod === "bearer" ? "API" : "WEB"; const { id } = await params; - // v1.4.25 — privacy gate hoisted to the shared helper. This is a - // dose write, so the guard also refuses a record-only medication with + // Privacy gate through the shared helper. This is a dose write, so the + // guard also refuses a record-only medication with // `medication.intake.notTracked` (iOS #116, item 15): its history stays, // but a new dose would be stored and then counted nowhere. const guard = await assertMedicationTakesIntake(id, user.id); @@ -120,8 +100,8 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { medicationId: id, }); if (!parsed.success) { - // v1.4.43 — per-med intake POST hot path; multi-issue 422 + - // audit breadcrumb keyed `medications.intake.create.validation-failed`. + // Per-med intake POST hot path; multi-issue 422 + audit breadcrumb keyed + // `medications.intake.create.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); const shape = unstableExternalIdShape(body, "idempotencyKey"); annotate({ @@ -134,12 +114,12 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { : {}), }, }); - // v1.4.49 — strip `message` from the audit-ledger row; the - // intake payload carries `idempotencyKey` (opaque caller string). + // Strip `message` from the audit-ledger row; the intake payload carries + // `idempotencyKey` (opaque caller string). const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - // v1.36.x — through `auditLog()`, the only writer that stamps the actor. + // Through `auditLog()`, the only writer that stamps the actor. void auditLog("medications.intake.create.validation-failed", { userId: user.id, details: { issues: auditIssues, medicationId: id }, @@ -149,515 +129,28 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { return returnAllZodIssues(parsed.error, 422); } - const { - scheduledFor, - takenAt, - skipped, - idempotencyKey, - injectionSite, - forceSlotInstant, - doseTaken, - } = parsed.data; - - // v1.16.4 — the dose override documents a consumed dose, so it only - // applies to a taken (non-skipped) write; on a skip it is silently - // dropped (the slot consumed nothing). - const resolvedDoseTaken = !skipped && doseTaken ? doseTaken : null; - - // v1.8.5 — resolve + server-validate the optional injection site. Load - // the medication's delivery form + tracking opt-in + per-medication - // allowed sites, plus the user's global exclusion deny-list. A site - // outside the effective allowed set is a hard 422; a site on a - // non-injection / tracking-off med (or a skip) is silently dropped. - let resolvedInjectionSite: InjectionSiteKey | null = null; - if (injectionSite !== undefined) { - const [med, userRow] = await Promise.all([ - prisma.medication.findUnique({ - where: { id }, - select: { - deliveryForm: true, - trackInjectionSites: true, - allowedInjectionSites: true, - }, - }), - prisma.user.findUnique({ - where: { id: user.id }, - select: { globalExcludedInjectionSites: true }, - }), - ]); - const resolution = resolveInjectionSiteForWrite({ - submitted: injectionSite, - taken: !skipped, - deliveryForm: med?.deliveryForm ?? "ORAL", - trackInjectionSites: med?.trackInjectionSites ?? false, - allowedInjectionSites: (med?.allowedInjectionSites ?? - []) as InjectionSiteKey[], - globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ?? - []) as InjectionSiteKey[], - }); - if (resolution.kind === "disallowed") { - annotate({ - action: { name: "medication.intake.injection_site.disallowed" }, - meta: { medication_id: id, site: resolution.site }, - }); - return apiError( - "Injection site is not allowed for this medication", - 422, - { - errorCode: "medications.intake.injection_site.disallowed", - }, - ); - } - resolvedInjectionSite = resolution.site; - } - - const resolvedTakenAt = skipped ? null : (takenAt ?? new Date()); - const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date(); - // The per-med route only ever carries an explicit user gesture: - // `resolvedTakenAt` is `now()` for any non-skip POST (there is no - // "mark pending" write on this route), so a non-skip is an explicit - // taken and a `skipped:true` body is an explicit skip. Neither is a - // pending projection echo, so the no-downgrade guard never trips here — - // it is the bulk/sync route that replays pending echoes. The flags are - // threaded through so the shared upsert applies last-write-wins. - const isExplicitTaken = !skipped; - const isExplicitSkip = skipped === true; - - // v1.15.18 — window-band slot attribution (replaces the wide ±6h - // `snapToleranceMs` nearest-snap). The take is bound to a slot by - // membership in that slot's configurable dose window — the SAME bands the - // read ledger + the compliance % consume, so the three surfaces can never - // disagree. A take that lands in no window is ad-hoc (`canonicalSlot` null) - // and records as a standalone "taken now" row; PRN meds always insert - // standalone. Resolved BEFORE the idempotency/dedup window so a scheduled - // dose routes through the slot upsert (which is itself the dedup). - // - // - SKIP: a skip is logged against a slot deliberately (it carries no - // `takenAt` to attribute by), so it keeps the canonical `scheduledFor` - // snap that binds it to the slot's pending REMINDER row. - // - TAKEN: attribute by `takenAt` band membership. The optional - // `forceSlotInstant` pins an off-window take onto a chosen real slot - // ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422. - let canonicalSlot: Date | null = null; - // v1.15.20 — binding provenance for the written row: USER_PIN on the - // forced "diesem Slot zuordnen" path, AUTO when band attribution decided. - // Skips carry no binding decision (undefined → column untouched/default). - let attributionSource: "AUTO" | "USER_PIN" | undefined; - if (skipped) { - if (scheduledFor === undefined) { - // v1.16.9 — a slot-less deliberate skip resolves by band membership - // on the skip moment, like a take does. The defaulted-now snap only - // reached the tight reminder-grace window, so a skip tapped past it - // landed as an orphan ad-hoc row while the slot's pending REMINDER - // row stayed open to auto-miss. - const attribution = await resolveSlotForWriteByBand({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - takenAt: incomingScheduledFor, - }); - canonicalSlot = attribution.slotInstant; - } else { - canonicalSlot = await resolveSlotInstantForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - incoming: incomingScheduledFor, - instantIsExplicit: true, - isTakenWrite: false, - }); - } - } else if (forceSlotInstant !== undefined) { - canonicalSlot = await resolveForcedSlotForWrite({ - userId: user.id, + const outcome = await recordIntake( + { + user, + actorId: actor.id, medicationId: id, - userTz: user.timezone, - slotInstant: forceSlotInstant, - }); - if (canonicalSlot === null) { - annotate({ - action: { name: "medication.intake.force_slot.invalid" }, - meta: { medication_id: id }, - }); - return apiError( - "forceSlotInstant is not a scheduled slot of this medication", - 422, - { errorCode: "medications.intake.force_slot.invalid" }, - ); - } - // v1.16.0 — refuse to pin onto a slot another recorded action already - // serves: the explicit-write last-write-wins rule would silently - // overwrite that dose record. The ledger UI only offers the pin for - // unserved slots, so this only fires for stale clients / raw API calls. - if ( - await findPinConflict({ - userId: user.id, - medicationId: id, - canonicalSlot, - incomingTakenAt: resolvedTakenAt ?? null, - }) - ) { - annotate({ - action: { name: "medication.intake.force_slot.occupied" }, - meta: { medication_id: id }, - }); - return apiError( - "forceSlotInstant already carries a recorded dose action", - 422, - { errorCode: "medications.intake.force_slot.occupied" }, - ); - } - attributionSource = "USER_PIN"; - } else { - // A non-skip write on this route always carries a `takenAt` (defaulted to - // now), so `resolvedTakenAt` is non-null here; the fallback only guards the - // type. - const attribution = await resolveSlotForWriteByBand({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - takenAt: resolvedTakenAt ?? incomingScheduledFor, - }); - canonicalSlot = attribution.slotInstant; - // A band decision (slot or ad-hoc) is an AUTO binding; it also resets a - // stale USER_PIN when this write converges onto a previously-pinned row. - attributionSource = "AUTO"; - - // The client can NAME the dose it is recording, and when it does, that - // answer outranks the band. The Verlauf ledger's "Genommen" posts the - // displayed slot's own anchor, and the medication card posts its - // display-due slot; both mean "record THIS dose". Band attribution answers - // a different question — which slot the take's clock time falls into — so - // backfilling a slot from an earlier day matched no band, fell through to - // the standalone insert, and left the named slot unserved: the entry - // reverted to missed on the next read and the dose the user recorded was - // filed on the wrong day. - // - // Only a REAL slot of this medication is honoured (the same band-anchor - // validation the "diesem Slot zuordnen" pin runs), so an arbitrary client - // instant still records ad-hoc rather than parking a live row on a - // fabricated anchor. And only a slot that does not sit ahead of the take, - // by the same forward guard the convergence probe below uses — a - // late-morning dose must never consume the evening slot. - if ( - scheduledFor !== undefined && - mayConvergeOntoSuppliedSlot({ - skipped, - takenAt: resolvedTakenAt, - suppliedSlot: incomingScheduledFor, - }) - ) { - const namedSlot = await resolveForcedSlotForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - slotInstant: incomingScheduledFor, - }); - // Only when the naming actually decides something the clock did not. - // A dose taken inside its own window resolves to the same anchor either - // way and stays an AUTO binding, so an ordinary on-time take is - // unchanged. When the two disagree the binding rests on the user's - // choice, which is what USER_PIN records — and the read ledger binds a - // USER_PIN take by its stored anchor rather than by the clock, so a - // backfilled dose reads under the slot it was recorded for instead of - // falling back to ad-hoc. - if ( - namedSlot !== null && - namedSlot.getTime() !== canonicalSlot?.getTime() - ) { - canonicalSlot = namedSlot; - attributionSource = "USER_PIN"; - } - } - } - - // Idempotency check (explicit key or server-side dedup window) - if (idempotencyKey) { - const existing = await prisma.medicationIntakeEvent.findFirst({ - where: { - idempotencyKey, - userId: user.id, - medicationId: id, - }, - }); - if (existing) { - return apiSuccess(existing); - } - } else if (!canonicalSlot) { - // Unscheduled / PRN only — the slot upsert handles dedup for - // scheduled doses by collapsing onto the canonical slot row. - // Server-side dedup: prevent double-logging within 60 seconds. - const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({ - where: { - userId: user.id, - medicationId: id, - skipped, - createdAt: { gte: new Date(Date.now() - 60_000) }, - }, - orderBy: { createdAt: "desc" }, - }); - if (recentDuplicate) { - return apiSuccess(recentDuplicate); - } - } - - let event; - // v1.8.2 — whether this write moved the slot pending→taken. - // Only that transition decrements pen inventory (M2). For the - // unscheduled/PRN branch a non-skip write always records a fresh dose, - // so it consumes when not skipped. - let consumedTransition = !skipped; - if (canonicalSlot) { - // Scheduled dose — converge onto the one canonical slot row regardless - // of `source` (the pending REMINDER row, or any prior row for this - // slot) through the shared upsert: H1 deterministic selection, C2 - // no-downgrade guard, and a C1 race-safe create that re-finds + updates - // on a P2002 collision rather than 500-ing or duplicating. - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId: user.id, - medicationId: id, - canonicalSlot, - takenAt: resolvedTakenAt, - skipped, - isExplicitTaken, - isExplicitSkip, - idempotencyKey: idempotencyKey ?? null, - createSource: intakeSource, - refuseOutcomeChange: isDelegated, - // v1.8.5 — resolved + validated site (null unless a tracking-on - // injection taken write supplied an allowed site). - injectionSite: resolvedInjectionSite, - attributionSource, - // v1.16.4 — per-intake dose override (null unless a taken write - // carried one). - doseTaken: resolvedDoseTaken, - }); - if (applied.outcomeChangeRefused) return refuseOutcomeChange(); - event = applied.row; - consumedTransition = applied.consumedTransition; - // Reset the snooze when a dose is actually recorded (not on a - // no-downgrade no-op, which left the prior taken row untouched). - if (!skipped && !applied.noDowngradeNoOp) { - await prisma.medication.update({ - where: { id }, - data: { snoozedUntil: null }, - }); - } - } else { - // Unscheduled / PRN / off-slot. When the client named an explicit - // `scheduledFor`, converge source-agnostically onto any live row that - // already sits on that instant (e.g. the pending REMINDER row the - // worker minted on a slot the band attribution did not claim) before - // inserting. Without the probe the insert lands a second live row for - // the same slot that differs only by `source` — the partial unique - // index carries `source` and cannot catch it — inflating the - // compliance rollup's scheduled count. A defaulted anchor (takenAt / - // now) never names a slot, so the probe is skipped on that hot path. - // - // Dose-safety guard: a TAKEN write must not converge onto a slot whose - // anchor is in the future relative to the take. The card advances its - // display-due to the evening slot once the morning slot's catch-up - // window lapses, so a late-morning "Genommen" posts the evening slot as - // `scheduledFor`; band attribution already rejected the take, and the - // probe must not re-bind it forward onto the evening pending row (a - // late-morning dose silently consuming the 21:00 slot). It records - // standalone (ad-hoc) instead. - const existingSlotRow = - scheduledFor !== undefined && - mayConvergeOntoSuppliedSlot({ - skipped, - takenAt: resolvedTakenAt, - suppliedSlot: incomingScheduledFor, - }) - ? await prisma.medicationIntakeEvent.findFirst({ - where: { - userId: user.id, - medicationId: id, - scheduledFor: incomingScheduledFor, - deletedAt: null, - }, - select: { id: true }, - }) - : null; - if (existingSlotRow) { - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId: user.id, - medicationId: id, - canonicalSlot: incomingScheduledFor, - takenAt: resolvedTakenAt, - skipped, - isExplicitTaken, - isExplicitSkip, - idempotencyKey: idempotencyKey ?? null, - createSource: intakeSource, - refuseOutcomeChange: isDelegated, - injectionSite: resolvedInjectionSite, - attributionSource, - doseTaken: resolvedDoseTaken, - }); - if (applied.outcomeChangeRefused) return refuseOutcomeChange(); - event = applied.row; - consumedTransition = applied.consumedTransition; - if (!skipped && !applied.noDowngradeNoOp) { - await prisma.medication.update({ - where: { id }, - data: { snoozedUntil: null }, - }); - } - } else { - // Genuinely standalone. Anchor a taken write on the intake instant — - // the documented ad-hoc contract (`scheduledFor = takenAt`) — so an - // unresolvable client anchor can never park a live row exactly on a - // slot instant a pending REMINDER row is minted for later. A skip - // without a slot keeps the incoming instant (it has no takenAt). - [event] = await prisma.$transaction([ - prisma.medicationIntakeEvent.create({ - data: { - userId: user.id, - medicationId: id, - scheduledFor: resolvedTakenAt ?? incomingScheduledFor, - takenAt: resolvedTakenAt, - skipped, - source: intakeSource, - idempotencyKey: idempotencyKey ?? null, - // v1.8.5 — site only on a resolved taken-injection write. - ...(resolvedInjectionSite !== null && { - injectionSite: resolvedInjectionSite, - }), - // v1.16.4 — dose override only on a taken write carrying one. - ...(resolvedDoseTaken !== null && { - doseTaken: resolvedDoseTaken, - }), - }, - }), - // Reset snooze when medication is taken - ...(!skipped - ? [ - prisma.medication.update({ - where: { id }, - data: { snoozedUntil: null }, - }), - ] - : []), - ]); - } - } - - // v1.16.10 — inventory consumption / restore. A taken write consumes - // `unitsPerDose` units (the stamp on the event row makes replays and - // re-posts exactly-once; `consumedTransition` skips the read for the - // no-op echo cases the slot upsert already classified). An explicit - // skip can downgrade a previously-taken row (last-write-wins), so it - // refunds whatever that row's stamp recorded. Both hooks are - // best-effort and never block the intake write. - if (!skipped && consumedTransition) { - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: id, - eventId: event.id, - intakeAt: event.takenAt ?? event.scheduledFor, - }); - } else if (skipped) { - await restoreForIntake({ - client: prisma, - userId: user.id, - eventId: event.id, - }); - } - - await auditLog("medication.intake", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - medicationId: id, - eventId: event.id, - skipped, + intakeSource, + isDelegated, + ipAddress: getClientIp(request), + originDeviceToken: request.headers.get("x-device-id"), }, - }); - - annotate({ - action: { - name: "medication.intake", - entity_type: "intake_event", - entity_id: event.id, - }, - meta: { - medication_id: id, - skipped, - }, - }); - - // v1.4.34 — bust per-user medications + compliance + achievement - // caches so the next read reflects the dose event. - invalidateUserMedications(user.id, { evict: true }); - - // v1.4.39 — refresh the persistent compliance rollup for the - // affected day. The hook is best-effort; failures annotate but never - // block the user's POST response. - await refreshDoseSlot({ - userId: user.id, - medicationId: id, - scheduledFor: event.scheduledFor, - tz: user.timezone, - }); - - // v1.5.0 — one-shot lifecycle reconciliation. A `oneShot` medication - // has at most one live intake; the helper re-reads the most recent - // non-skipped intake and flips `active` to match. Idempotent on - // non-one-shot medications (the underlying updateMany is gated by - // `oneShot:true`). The flip runs AFTER the intake row is committed - // so a flaky write never deactivates a medication that didn't - // actually receive its dose. - const reconcileAction = await reconcileOneShotState(prisma, id, user.id); - if (reconcileAction !== "noop") { - invalidateUserMedications(user.id, { evict: true }); + parsed.data, + ); + switch (outcome.kind) { + case "refused": + return apiError(outcome.message, 422, { errorCode: outcome.errorCode }); + case "existing": + return apiSuccess(outcome.row); + case "outcome_change_refused": + return refuseOutcomeChange(); + case "recorded": + return apiSuccess(outcome.event, 201); } - - // (#22) — silent cross-device intake sync: wake the user's OTHER iOS - // devices so a running Live Activity / widget reconciles this dose. - // Coalesced per user, best-effort — never affects the response. - queueMedicationIntakeSync({ - userId: user.id, - originDeviceToken: request.headers.get("x-device-id"), - }); - - // PWA counterpart of the sync wake above: a dose resolved here (taken or - // skipped) closes the still-pending dose-due Web Push reminder for the - // slot and refreshes the app badge. The other intake routes have carried - // this since v1.18.4; this route is the replay target of the iOS offline - // queue, so a drained dose must clear the web reminder the same way. - // Best-effort, fire-and-forget — the canonical row is already persisted. - void (async () => { - const badgeCount = await countOutstandingDosesToday(user.id, user.timezone); - await dispatchMedicationIntakeWebClear({ - userId: user.id, - medicationId: id, - scheduledFor: event.scheduledFor.toISOString(), - badgeCount, - }); - })(); - - // v1.36.x — "somebody else marked your dose". This route has no snooze arm, - // so the state is whichever of the two markings the payload carried. The - // helper refuses on self, so a person marking their own dose is unaffected. - // Fire and forget, like every neighbour above. The dispatcher awaits each - // channel in the cascade, so an unreachable one would have added its whole - // timeout to the caregiver's request — the person standing next to the - // patient waiting for a dose to register. The helper never throws, and the - // owner learning a moment later is the right trade against the tick being - // slow. The integration test already polls the ledger rather than relying on - // the await, so nothing about the proof weakens. - void notifyDelegatedIntake({ - ownerId: user.id, - actorId: actor.id, - medicationId: id, - status: skipped ? "skipped" : "taken", - }); - - return apiSuccess(event, 201); } export const GET = apiHandler( @@ -665,28 +158,28 @@ export const GET = apiHandler( const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; - // v1.4.25 — privacy gate hoisted to the shared helper. + // Privacy gate through the shared helper. const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; const searchParams = Object.fromEntries(request.nextUrl.searchParams); const parsed = listIntakeEventsSchema.safeParse(searchParams); if (!parsed.success) { - // v1.4.43 — multi-issue 422 + audit breadcrumb keyed + // Multi-issue 422 + audit breadcrumb keyed // `medications.intake.list.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); annotate({ action: { name: "medications.intake.list.validation-failed" }, meta: { issue_count: issues.length, medication_id: id }, }); - // v1.4.49 — strip `message` from the audit-ledger row. + // Strip `message` from the audit-ledger row. const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only thing that stamps - // `actorUserId`. Filed under the resolved record either way; without - // the stamp a delegate's malformed query would read as the owner's own. + // Through `auditLog()` rather than a bare `prisma.auditLog.create`, + // because that helper is the only thing that stamps `actorUserId`. + // Filed under the resolved record either way; without the stamp a + // delegate's malformed query would read as the owner's own. void auditLog("medications.intake.list.validation-failed", { userId: user.id, details: { issues: auditIssues, medicationId: id }, @@ -696,72 +189,12 @@ export const GET = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { limit, offset, sortBy, sortDir, status } = parsed.data; - - // v1.4.37 — translate the optional `status` filter into a Prisma - // `where` fragment. Default `status:"all"` keeps the contract - // byte-stable for the iOS Swift client and the dashboard tiles that - // were on the wire before this knob existed. The detail-page - // IntakeHistoryListV2 component opts into `status:"completed"` so - // ambiguous "missed / never confirmed" rows - // (`takenAt IS NULL AND skipped = false`) stay out of the user-facing - // table — they were the source of the v1.4.36 regression where rows - // with no takenAt rendered an "Eingenommen" chip. - const statusFilter = - status === "taken" - ? { takenAt: { not: null }, skipped: false } - : status === "skipped" - ? { skipped: true } - : status === "completed" - ? { - OR: [ - { takenAt: { not: null }, skipped: false }, - { skipped: true }, - ], - } - : {}; - // v1.7.0 sync — exclude tombstoned rows from the per-medication - // intake history list + its count. - const where = { - medicationId: id, - userId: user.id, - deletedAt: null, - ...statusFilter, - }; - - // v1.7.0 — pin NULLS LAST on the `takenAt` sort. Skipped rows - // carry `takenAt: null`; under a bare `desc` collation Postgres - // emits NULLS FIRST, floating skipped/planned rows to the top of - // the history view. Pinning them last keeps the descending order - // reading today → yesterday → … with real timestamps first. Other - // sort columns are non-null so they keep the simple shape. - // The trailing `{ id: sortDir }` is the unique tiebreaker. Scheduled - // slots share an instant by construction — a twice-daily medication puts - // every morning dose on the same `scheduledFor` — so offset paging over - // the primary key alone can repeat a row on one page and drop another. - const orderBy = - sortBy === "takenAt" - ? [ - { takenAt: { sort: sortDir, nulls: "last" as const } }, - { id: sortDir }, - ] - : [{ [sortBy]: sortDir }, { id: sortDir }]; - - const [events, total] = await Promise.all([ - prisma.medicationIntakeEvent.findMany({ - where, - orderBy, - take: limit, - skip: offset, + return apiSuccess( + await listIntakeEvents({ + userId: user.id, + medicationId: id, + query: parsed.data, }), - prisma.medicationIntakeEvent.count({ where }), - ]); - - annotate({ - action: { name: "medication.intake.list" }, - meta: { medication_id: id, total, status }, - }); - - return apiSuccess({ events, meta: { total, limit, offset } }); + ); }, ); diff --git a/src/lib/medications/list-intake-events.ts b/src/lib/medications/list-intake-events.ts new file mode 100644 index 000000000..6a58e2b11 --- /dev/null +++ b/src/lib/medications/list-intake-events.ts @@ -0,0 +1,89 @@ +/** + * One medication's intake history: `GET /api/medications/{id}/intake`. + * + * The route authenticates (READ over the `medications` section), runs the + * ownership guard and parses the query; the filter, the order, the page and + * the count are built here. + */ +import type { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import type { listIntakeEventsSchema } from "@/lib/validations/medication"; + +export type IntakeEventListQuery = z.output; + +export async function listIntakeEvents(input: { + userId: string; + medicationId: string; + query: IntakeEventListQuery; +}) { + const { userId, medicationId: id } = input; + const { limit, offset, sortBy, sortDir, status } = input.query; + + // Translate the optional `status` filter into a Prisma + // `where` fragment. Default `status:"all"` keeps the contract + // byte-stable for the iOS Swift client and the dashboard tiles that + // were on the wire before this knob existed. The detail-page + // IntakeHistoryListV2 component opts into `status:"completed"` so + // ambiguous "missed / never confirmed" rows + // (`takenAt IS NULL AND skipped = false`) stay out of the user-facing + // table — they were the source of the v1.4.36 regression where rows + // with no takenAt rendered an "Eingenommen" chip. + const statusFilter = + status === "taken" + ? { takenAt: { not: null }, skipped: false } + : status === "skipped" + ? { skipped: true } + : status === "completed" + ? { + OR: [ + { takenAt: { not: null }, skipped: false }, + { skipped: true }, + ], + } + : {}; + // Exclude tombstoned rows from the per-medication + // intake history list + its count. + const where = { + medicationId: id, + userId, + deletedAt: null, + ...statusFilter, + }; + + // Pin NULLS LAST on the `takenAt` sort. Skipped rows + // carry `takenAt: null`; under a bare `desc` collation Postgres + // emits NULLS FIRST, floating skipped/planned rows to the top of + // the history view. Pinning them last keeps the descending order + // reading today → yesterday → … with real timestamps first. Other + // sort columns are non-null so they keep the simple shape. + // The trailing `{ id: sortDir }` is the unique tiebreaker. Scheduled + // slots share an instant by construction — a twice-daily medication puts + // every morning dose on the same `scheduledFor` — so offset paging over + // the primary key alone can repeat a row on one page and drop another. + const orderBy = + sortBy === "takenAt" + ? [ + { takenAt: { sort: sortDir, nulls: "last" as const } }, + { id: sortDir }, + ] + : [{ [sortBy]: sortDir }, { id: sortDir }]; + + const [events, total] = await Promise.all([ + prisma.medicationIntakeEvent.findMany({ + where, + orderBy, + take: limit, + skip: offset, + }), + prisma.medicationIntakeEvent.count({ where }), + ]); + + annotate({ + action: { name: "medication.intake.list" }, + meta: { medication_id: id, total, status }, + }); + + return { events, meta: { total, limit, offset } }; +} diff --git a/src/lib/medications/record-intake/attribution.ts b/src/lib/medications/record-intake/attribution.ts new file mode 100644 index 000000000..8a1417cc5 --- /dev/null +++ b/src/lib/medications/record-intake/attribution.ts @@ -0,0 +1,223 @@ +/** + * Which slot a dose posted to `POST /api/medications/{id}/intake` binds to, + * and the binding provenance it records. + * + * Window-band slot attribution (replacing the wide ±6h `snapToleranceMs` + * nearest-snap). The take is bound to a slot by membership in that slot's + * configurable dose window — the SAME bands the read ledger + the compliance + * % consume, so the three surfaces can never disagree. A take that lands in + * no window is ad-hoc (`canonicalSlot` null) and records as a standalone + * "taken now" row; PRN meds always insert standalone. Resolved BEFORE the + * idempotency/dedup window so a scheduled dose routes through the slot upsert + * (which is itself the dedup). + * + * - SKIP: a skip is logged against a slot deliberately (it carries no + * `takenAt` to attribute by), so it keeps the canonical `scheduledFor` + * snap that binds it to the slot's pending REMINDER row. + * - TAKEN: attribute by `takenAt` band membership. The optional + * `forceSlotInstant` pins an off-window take onto a chosen real slot + * ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422. + */ +import { annotate } from "@/lib/logging/context"; +import type { SessionUser } from "@/lib/auth/session-user"; +import { + findPinConflict, + mayConvergeOntoSuppliedSlot, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, + resolveSlotInstantForWrite, +} from "@/lib/medications/scheduling/slot-upsert"; + +/** A coded 422 refusal the route answers with `apiError`. */ +export interface IntakeWriteRefusal { + kind: "refused"; + message: string; + errorCode: string; +} + +export type AttributionSource = "AUTO" | "USER_PIN"; + +export interface IntakeAttribution { + kind: "attributed"; + canonicalSlot: Date | null; + /** + * USER_PIN on the forced "diesem Slot zuordnen" path, AUTO when band + * attribution decided. Skips carry no binding decision (undefined → column + * untouched/default). + */ + attributionSource: AttributionSource | undefined; +} + +interface AttributionInput { + user: Pick; + medicationId: string; + skipped: boolean; + scheduledFor: Date | undefined; + forceSlotInstant: Date | undefined; + resolvedTakenAt: Date | null; + incomingScheduledFor: Date; +} + +async function attributeSkip(input: AttributionInput): Promise { + const { user, medicationId: id, scheduledFor, incomingScheduledFor } = input; + if (scheduledFor === undefined) { + // A slot-less deliberate skip resolves by band membership + // on the skip moment, like a take does. The defaulted-now snap only + // reached the tight reminder-grace window, so a skip tapped past it + // landed as an orphan ad-hoc row while the slot's pending REMINDER + // row stayed open to auto-miss. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: incomingScheduledFor, + }); + return attribution.slotInstant; + } + return resolveSlotInstantForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + incoming: incomingScheduledFor, + instantIsExplicit: true, + isTakenWrite: false, + }); +} + +async function attributePin( + input: AttributionInput, + forceSlotInstant: Date, +): Promise { + const { user, medicationId: id, resolvedTakenAt } = input; + const canonicalSlot = await resolveForcedSlotForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + slotInstant: forceSlotInstant, + }); + if (canonicalSlot === null) { + annotate({ + action: { name: "medication.intake.force_slot.invalid" }, + meta: { medication_id: id }, + }); + return { + kind: "refused", + message: "forceSlotInstant is not a scheduled slot of this medication", + errorCode: "medications.intake.force_slot.invalid", + }; + } + // Refuse to pin onto a slot another recorded action already + // serves: the explicit-write last-write-wins rule would silently + // overwrite that dose record. The ledger UI only offers the pin for + // unserved slots, so this only fires for stale clients / raw API calls. + if ( + await findPinConflict({ + userId: user.id, + medicationId: id, + canonicalSlot, + incomingTakenAt: resolvedTakenAt ?? null, + }) + ) { + annotate({ + action: { name: "medication.intake.force_slot.occupied" }, + meta: { medication_id: id }, + }); + return { + kind: "refused", + message: "forceSlotInstant already carries a recorded dose action", + errorCode: "medications.intake.force_slot.occupied", + }; + } + return { kind: "attributed", canonicalSlot, attributionSource: "USER_PIN" }; +} + +async function attributeTake( + input: AttributionInput, +): Promise { + const { + user, + medicationId: id, + skipped, + scheduledFor, + resolvedTakenAt, + incomingScheduledFor, + } = input; + // A non-skip write on this route always carries a `takenAt` (defaulted to + // now), so `resolvedTakenAt` is non-null here; the fallback only guards the + // type. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: resolvedTakenAt ?? incomingScheduledFor, + }); + let canonicalSlot = attribution.slotInstant; + // A band decision (slot or ad-hoc) is an AUTO binding; it also resets a + // stale USER_PIN when this write converges onto a previously-pinned row. + let attributionSource: AttributionSource = "AUTO"; + + // The client can NAME the dose it is recording, and when it does, that + // answer outranks the band. The Verlauf ledger's "Genommen" posts the + // displayed slot's own anchor, and the medication card posts its + // display-due slot; both mean "record THIS dose". Band attribution answers + // a different question — which slot the take's clock time falls into — so + // backfilling a slot from an earlier day matched no band, fell through to + // the standalone insert, and left the named slot unserved: the entry + // reverted to missed on the next read and the dose the user recorded was + // filed on the wrong day. + // + // Only a REAL slot of this medication is honoured (the same band-anchor + // validation the "diesem Slot zuordnen" pin runs), so an arbitrary client + // instant still records ad-hoc rather than parking a live row on a + // fabricated anchor. And only a slot that does not sit ahead of the take, + // by the same forward guard the convergence probe uses — a + // late-morning dose must never consume the evening slot. + if ( + scheduledFor !== undefined && + mayConvergeOntoSuppliedSlot({ + skipped, + takenAt: resolvedTakenAt, + suppliedSlot: incomingScheduledFor, + }) + ) { + const namedSlot = await resolveForcedSlotForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + slotInstant: incomingScheduledFor, + }); + // Only when the naming actually decides something the clock did not. + // A dose taken inside its own window resolves to the same anchor either + // way and stays an AUTO binding, so an ordinary on-time take is + // unchanged. When the two disagree the binding rests on the user's + // choice, which is what USER_PIN records — and the read ledger binds a + // USER_PIN take by its stored anchor rather than by the clock, so a + // backfilled dose reads under the slot it was recorded for instead of + // falling back to ad-hoc. + if ( + namedSlot !== null && + namedSlot.getTime() !== canonicalSlot?.getTime() + ) { + canonicalSlot = namedSlot; + attributionSource = "USER_PIN"; + } + } + return { kind: "attributed", canonicalSlot, attributionSource }; +} + +/** The slot the dose binds to, or the coded refusal of a bad pin. */ +export async function resolveIntakeAttribution( + input: AttributionInput, +): Promise { + if (input.skipped) { + return { + kind: "attributed", + canonicalSlot: await attributeSkip(input), + attributionSource: undefined, + }; + } + if (input.forceSlotInstant !== undefined) { + return attributePin(input, input.forceSlotInstant); + } + return attributeTake(input); +} diff --git a/src/lib/medications/record-intake/record-intake.ts b/src/lib/medications/record-intake/record-intake.ts new file mode 100644 index 000000000..b87562e93 --- /dev/null +++ b/src/lib/medications/record-intake/record-intake.ts @@ -0,0 +1,310 @@ +/** + * Record one dose of one medication: `POST /api/medications/{id}/intake`. + * + * The route authenticates (WRITE over the `medications` section), runs the + * intake guard, parses the body and answers the schema refusal and the + * delegated outcome-change refusal; the injection-site check, the slot + * attribution, the replay and dedup answers, the write and every tail after + * it run here, in the order they always ran. + */ +import type { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import type { SessionUser } from "@/lib/auth/session-user"; +import type { intakeSchema } from "@/lib/validations/medication"; +import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; +import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; +import { reconcileOneShotState } from "@/lib/medications/lifecycle"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; +import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; +import { + resolveIntakeAttribution, + type IntakeWriteRefusal, +} from "./attribution"; +import { + findReplayedIntake, + writeIntake, + type IntakeWriteResult, +} from "./write"; + +export type IntakeInput = z.output; + +type WrittenIntake = Extract; + +export type RecordIntakeOutcome = + | IntakeWriteRefusal + | Exclude + | { kind: "recorded"; event: WrittenIntake["event"] }; + +interface RecordIntakeContext { + user: Pick; + actorId: string; + medicationId: string; + intakeSource: "API" | "WEB"; + isDelegated: boolean; + ipAddress: string | null; + originDeviceToken: string | null; +} + +/** + * Resolve + server-validate the optional injection site. Load the + * medication's delivery form + tracking opt-in + per-medication allowed + * sites, plus the user's global exclusion deny-list. A site outside the + * effective allowed set is a hard 422; a site on a non-injection / + * tracking-off med (or a skip) is silently dropped. + */ +async function resolveInjectionSite( + ctx: RecordIntakeContext, + data: IntakeInput, +): Promise< + { kind: "site"; site: InjectionSiteKey | null } | IntakeWriteRefusal +> { + const { user, medicationId: id } = ctx; + const { injectionSite, skipped } = data; + let resolvedInjectionSite: InjectionSiteKey | null = null; + if (injectionSite !== undefined) { + const [med, userRow] = await Promise.all([ + prisma.medication.findUnique({ + where: { id }, + select: { + deliveryForm: true, + trackInjectionSites: true, + allowedInjectionSites: true, + }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { globalExcludedInjectionSites: true }, + }), + ]); + const resolution = resolveInjectionSiteForWrite({ + submitted: injectionSite, + taken: !skipped, + deliveryForm: med?.deliveryForm ?? "ORAL", + trackInjectionSites: med?.trackInjectionSites ?? false, + allowedInjectionSites: (med?.allowedInjectionSites ?? + []) as InjectionSiteKey[], + globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ?? + []) as InjectionSiteKey[], + }); + if (resolution.kind === "disallowed") { + annotate({ + action: { name: "medication.intake.injection_site.disallowed" }, + meta: { medication_id: id, site: resolution.site }, + }); + return { + kind: "refused", + message: "Injection site is not allowed for this medication", + errorCode: "medications.intake.injection_site.disallowed", + }; + } + resolvedInjectionSite = resolution.site; + } + return { kind: "site", site: resolvedInjectionSite }; +} + +/** + * Inventory consumption / restore. A taken write consumes `unitsPerDose` + * units (the stamp on the event row makes replays and re-posts exactly-once; + * `consumedTransition` skips the read for the no-op echo cases the slot + * upsert already classified). An explicit skip can downgrade a + * previously-taken row (last-write-wins), so it refunds whatever that row's + * stamp recorded. Both hooks are best-effort and never block the intake + * write. + */ +async function settleInventory( + ctx: RecordIntakeContext, + skipped: boolean, + written: WrittenIntake, +): Promise { + const { user, medicationId: id } = ctx; + const { event, consumedTransition } = written; + if (!skipped && consumedTransition) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId: event.id, + intakeAt: event.takenAt ?? event.scheduledFor, + }); + } else if (skipped) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: event.id, + }); + } +} + +/** The audit row, the event, the caches, the rollup and the lifecycle. */ +async function recordAfterWrite( + ctx: RecordIntakeContext, + skipped: boolean, + event: WrittenIntake["event"], +): Promise { + const { user, medicationId: id } = ctx; + await auditLog("medication.intake", { + userId: user.id, + ipAddress: ctx.ipAddress, + details: { + medicationId: id, + eventId: event.id, + skipped, + }, + }); + + annotate({ + action: { + name: "medication.intake", + entity_type: "intake_event", + entity_id: event.id, + }, + meta: { + medication_id: id, + skipped, + }, + }); + + // Bust per-user medications + compliance + achievement + // caches so the next read reflects the dose event. + invalidateUserMedications(user.id, { evict: true }); + + // Refresh the persistent compliance rollup for the + // affected day. The hook is best-effort; failures annotate but never + // block the user's POST response. + await refreshDoseSlot({ + userId: user.id, + medicationId: id, + scheduledFor: event.scheduledFor, + tz: user.timezone, + }); + + // One-shot lifecycle reconciliation. A `oneShot` medication + // has at most one live intake; the helper re-reads the most recent + // non-skipped intake and flips `active` to match. Idempotent on + // non-one-shot medications (the underlying updateMany is gated by + // `oneShot:true`). The flip runs AFTER the intake row is committed + // so a flaky write never deactivates a medication that didn't + // actually receive its dose. + const reconcileAction = await reconcileOneShotState(prisma, id, user.id); + if (reconcileAction !== "noop") { + invalidateUserMedications(user.id, { evict: true }); + } +} + +/** The tails that never affect the response. */ +function startTails( + ctx: RecordIntakeContext, + skipped: boolean, + event: WrittenIntake["event"], +): void { + const { user, medicationId: id } = ctx; + // (#22) — silent cross-device intake sync: wake the user's OTHER iOS + // devices so a running Live Activity / widget reconciles this dose. + // Coalesced per user, best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: ctx.originDeviceToken, + }); + + // PWA counterpart of the sync wake above: a dose resolved here (taken or + // skipped) closes the still-pending dose-due Web Push reminder for the + // slot and refreshes the app badge. The other intake routes have carried + // this since v1.18.4; this route is the replay target of the iOS offline + // queue, so a drained dose must clear the web reminder the same way. + // Best-effort, fire-and-forget — the canonical row is already persisted. + void (async () => { + const badgeCount = await countOutstandingDosesToday(user.id, user.timezone); + await dispatchMedicationIntakeWebClear({ + userId: user.id, + medicationId: id, + scheduledFor: event.scheduledFor.toISOString(), + badgeCount, + }); + })(); + + // "Somebody else marked your dose". This route has no snooze arm, + // so the state is whichever of the two markings the payload carried. The + // helper refuses on self, so a person marking their own dose is unaffected. + // Fire and forget, like every neighbour above. The dispatcher awaits each + // channel in the cascade, so an unreachable one would have added its whole + // timeout to the caregiver's request — the person standing next to the + // patient waiting for a dose to register. The helper never throws, and the + // owner learning a moment later is the right trade against the tick being + // slow. The integration test already polls the ledger rather than relying on + // the await, so nothing about the proof weakens. + void notifyDelegatedIntake({ + ownerId: user.id, + actorId: ctx.actorId, + medicationId: id, + status: skipped ? "skipped" : "taken", + }); +} + +/** Record the dose the route parsed. */ +export async function recordIntake( + ctx: RecordIntakeContext, + data: IntakeInput, +): Promise { + const { user, medicationId: id } = ctx; + const { scheduledFor, takenAt, skipped, idempotencyKey, doseTaken } = data; + + // The dose override documents a consumed dose, so it only + // applies to a taken (non-skipped) write; on a skip it is silently + // dropped (the slot consumed nothing). + const resolvedDoseTaken = !skipped && doseTaken ? doseTaken : null; + + const site = await resolveInjectionSite(ctx, data); + if (site.kind === "refused") return site; + + const resolvedTakenAt = skipped ? null : (takenAt ?? new Date()); + const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date(); + + const attribution = await resolveIntakeAttribution({ + user, + medicationId: id, + skipped, + scheduledFor, + forceSlotInstant: data.forceSlotInstant, + resolvedTakenAt, + incomingScheduledFor, + }); + if (attribution.kind === "refused") return attribution; + + const write = { + userId: user.id, + medicationId: id, + canonicalSlot: attribution.canonicalSlot, + attributionSource: attribution.attributionSource, + scheduledFor, + skipped, + idempotencyKey, + resolvedTakenAt, + incomingScheduledFor, + intakeSource: ctx.intakeSource, + isDelegated: ctx.isDelegated, + resolvedInjectionSite: site.site, + resolvedDoseTaken, + }; + const replayed = await findReplayedIntake(write); + if (replayed) return { kind: "existing", row: replayed }; + + const written = await writeIntake(write); + if (written.kind !== "written") return written; + + await settleInventory(ctx, skipped, written); + await recordAfterWrite(ctx, skipped, written.event); + startTails(ctx, skipped, written.event); + + return { kind: "recorded", event: written.event }; +} diff --git a/src/lib/medications/record-intake/write.ts b/src/lib/medications/record-intake/write.ts new file mode 100644 index 000000000..4db4ca049 --- /dev/null +++ b/src/lib/medications/record-intake/write.ts @@ -0,0 +1,238 @@ +/** + * The row a dose posted to `POST /api/medications/{id}/intake` lands on: the + * replay and dedup answers, the canonical slot write, the convergence probe + * onto a row already on the named instant, or the standalone insert. + */ +import type { MedicationIntakeEvent } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; +import { + applyCanonicalSlotWrite, + mayConvergeOntoSuppliedSlot, + type ApplyCanonicalSlotWriteResult, +} from "@/lib/medications/scheduling/slot-upsert"; +import type { AttributionSource } from "./attribution"; + +type IntakeRow = MedicationIntakeEvent | ApplyCanonicalSlotWriteResult["row"]; + +export interface IntakeWrite { + userId: string; + medicationId: string; + canonicalSlot: Date | null; + attributionSource: AttributionSource | undefined; + scheduledFor: Date | undefined; + skipped: boolean; + idempotencyKey: string | undefined; + resolvedTakenAt: Date | null; + incomingScheduledFor: Date; + intakeSource: "API" | "WEB"; + isDelegated: boolean; + resolvedInjectionSite: InjectionSiteKey | null; + resolvedDoseTaken: string | null; +} + +export type IntakeWriteResult = + | { kind: "existing"; row: MedicationIntakeEvent } + | { kind: "outcome_change_refused" } + | { kind: "written"; event: IntakeRow; consumedTransition: boolean }; + +/** + * Idempotency check (explicit key or server-side dedup window). The row a + * replay or a double-log answers with, or null when the dose is new. + */ +export async function findReplayedIntake( + write: IntakeWrite, +): Promise { + const { userId, medicationId: id, idempotencyKey, skipped } = write; + if (idempotencyKey) { + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { + idempotencyKey, + userId, + medicationId: id, + }, + }); + if (existing) { + return existing; + } + } else if (!write.canonicalSlot) { + // Unscheduled / PRN only — the slot upsert handles dedup for + // scheduled doses by collapsing onto the canonical slot row. + // Server-side dedup: prevent double-logging within 60 seconds. + const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({ + where: { + userId, + medicationId: id, + skipped, + createdAt: { gte: new Date(Date.now() - 60_000) }, + }, + orderBy: { createdAt: "desc" }, + }); + if (recentDuplicate) { + return recentDuplicate; + } + } + return null; +} + +/** + * Converge onto the one slot row through the shared upsert: H1 deterministic + * selection, C2 no-downgrade guard, and a C1 race-safe create that re-finds + + * updates on a P2002 collision rather than 500-ing or duplicating. + */ +async function writeOntoSlot( + write: IntakeWrite, + canonicalSlot: Date, +): Promise { + const { userId, medicationId: id, skipped } = write; + // The per-med route only ever carries an explicit user gesture: + // `resolvedTakenAt` is `now()` for any non-skip POST (there is no + // "mark pending" write on this route), so a non-skip is an explicit + // taken and a `skipped:true` body is an explicit skip. Neither is a + // pending projection echo, so the no-downgrade guard never trips here — + // it is the bulk/sync route that replays pending echoes. The flags are + // threaded through so the shared upsert applies last-write-wins. + const isExplicitTaken = !skipped; + const isExplicitSkip = skipped === true; + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId, + medicationId: id, + canonicalSlot, + takenAt: write.resolvedTakenAt, + skipped, + isExplicitTaken, + isExplicitSkip, + idempotencyKey: write.idempotencyKey ?? null, + createSource: write.intakeSource, + refuseOutcomeChange: write.isDelegated, + // Resolved + validated site (null unless a tracking-on + // injection taken write supplied an allowed site). + injectionSite: write.resolvedInjectionSite, + attributionSource: write.attributionSource, + // Per-intake dose override (null unless a taken write + // carried one). + doseTaken: write.resolvedDoseTaken, + }); + if (applied.outcomeChangeRefused) return { kind: "outcome_change_refused" }; + // Reset the snooze when a dose is actually recorded (not on a + // no-downgrade no-op, which left the prior taken row untouched). + if (!skipped && !applied.noDowngradeNoOp) { + await prisma.medication.update({ + where: { id }, + data: { snoozedUntil: null }, + }); + } + return { + kind: "written", + event: applied.row, + consumedTransition: applied.consumedTransition, + }; +} + +/** + * Genuinely standalone. Anchor a taken write on the intake instant — the + * documented ad-hoc contract (`scheduledFor = takenAt`) — so an unresolvable + * client anchor can never park a live row exactly on a slot instant a pending + * REMINDER row is minted for later. A skip without a slot keeps the incoming + * instant (it has no takenAt). + */ +async function insertStandalone(write: IntakeWrite): Promise { + const { + userId, + medicationId: id, + skipped, + resolvedTakenAt, + resolvedInjectionSite, + resolvedDoseTaken, + } = write; + const [event] = await prisma.$transaction([ + prisma.medicationIntakeEvent.create({ + data: { + userId, + medicationId: id, + scheduledFor: resolvedTakenAt ?? write.incomingScheduledFor, + takenAt: resolvedTakenAt, + skipped, + source: write.intakeSource, + idempotencyKey: write.idempotencyKey ?? null, + // Site only on a resolved taken-injection write. + ...(resolvedInjectionSite !== null && { + injectionSite: resolvedInjectionSite, + }), + // Dose override only on a taken write carrying one. + ...(resolvedDoseTaken !== null && { + doseTaken: resolvedDoseTaken, + }), + }, + }), + // Reset snooze when medication is taken + ...(!skipped + ? [ + prisma.medication.update({ + where: { id }, + data: { snoozedUntil: null }, + }), + ] + : []), + ]); + return event; +} + +/** Write the dose; the caller already answered a replay. */ +export async function writeIntake( + write: IntakeWrite, +): Promise { + const { userId, medicationId: id, skipped, scheduledFor } = write; + if (write.canonicalSlot) { + // Scheduled dose — converge onto the one canonical slot row regardless + // of `source` (the pending REMINDER row, or any prior row for this + // slot). + return writeOntoSlot(write, write.canonicalSlot); + } + // Unscheduled / PRN / off-slot. When the client named an explicit + // `scheduledFor`, converge source-agnostically onto any live row that + // already sits on that instant (e.g. the pending REMINDER row the + // worker minted on a slot the band attribution did not claim) before + // inserting. Without the probe the insert lands a second live row for + // the same slot that differs only by `source` — the partial unique + // index carries `source` and cannot catch it — inflating the + // compliance rollup's scheduled count. A defaulted anchor (takenAt / + // now) never names a slot, so the probe is skipped on that hot path. + // + // Dose-safety guard: a TAKEN write must not converge onto a slot whose + // anchor is in the future relative to the take. The card advances its + // display-due to the evening slot once the morning slot's catch-up + // window lapses, so a late-morning "Genommen" posts the evening slot as + // `scheduledFor`; band attribution already rejected the take, and the + // probe must not re-bind it forward onto the evening pending row (a + // late-morning dose silently consuming the 21:00 slot). It records + // standalone (ad-hoc) instead. + const existingSlotRow = + scheduledFor !== undefined && + mayConvergeOntoSuppliedSlot({ + skipped, + takenAt: write.resolvedTakenAt, + suppliedSlot: write.incomingScheduledFor, + }) + ? await prisma.medicationIntakeEvent.findFirst({ + where: { + userId, + medicationId: id, + scheduledFor: write.incomingScheduledFor, + deletedAt: null, + }, + select: { id: true }, + }) + : null; + if (existingSlotRow) { + return writeOntoSlot(write, write.incomingScheduledFor); + } + // For the unscheduled/PRN branch a non-skip write always records a fresh + // dose, so it consumes when not skipped. + return { + kind: "written", + event: await insertStandalone(write), + consumedTransition: !skipped, + }; +} diff --git a/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts b/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts index 40641fa46..4dd0a94c1 100644 --- a/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts +++ b/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts @@ -244,12 +244,64 @@ describe("the matcher sees what it looks for", () => { }); }); +/** + * Delegable write routes whose handler bodies live in service modules under + * `src/lib/`, keyed by the route relative to `src/`. The route keeps the + * resolver call, so membership is read off the route; the awaited audit + * actions are read off the route AND these modules, because that is where the + * rows are written now. Each module has to be imported by its route (leg + * below), so an entry cannot outlive the delegation it describes. + */ +const HANDLER_BODY_MODULES: Record = { + "app/api/medications/route.ts": ["lib/medications/create-medication.ts"], + "app/api/medications/[id]/intake/route.ts": [ + "lib/medications/record-intake/record-intake.ts", + ], +}; + +function handlerAuditActions(file: string): string[] { + const rel = relative(join(process.cwd(), "src"), file); + return (HANDLER_BODY_MODULES[rel] ?? []).flatMap((body) => { + const path = join(process.cwd(), "src", body); + return moduleFacts(readFileSync(path, "utf8"), path).awaitedAuditActions; + }); +} + describe("the owner's activity view names what a delegate did", () => { const routes = routeFiles(API); const writeModules = routes - .map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) })) + .map((file) => { + const facts = moduleFacts(readFileSync(file, "utf8"), file); + return { + file, + writesToRecord: facts.writesToRecord, + awaitedAuditActions: [ + ...facts.awaitedAuditActions, + ...handlerAuditActions(file), + ], + }; + }) .filter((m) => m.writesToRecord); + it("reads each moved handler body where its route imports it from", () => { + const entries = Object.entries(HANDLER_BODY_MODULES); + expect(entries.length).toBeGreaterThan(0); + for (const [route, modules] of entries) { + const source = readFileSync(join(process.cwd(), "src", route), "utf8"); + expect(moduleFacts(source, route).writesToRecord, route).toBe(true); + for (const body of modules) { + const specifier = `@/${body.replace(/\.ts$/, "")}`; + expect(source, `${route} does not import ${specifier}`).toContain( + `"${specifier}"`, + ); + expect( + handlerAuditActions(join(process.cwd(), "src", route)).length, + `${body} awaits no audit row`, + ).toBeGreaterThan(0); + } + } + }); + it("reads a plausible number of route modules", () => { // A walk that finds nothing agrees with a map that covers nothing. expect(routes.length).toBeGreaterThan(300);