From 62b050f711a91acf0375bcd6333abc9852ea494a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:40:06 +0200 Subject: [PATCH] refactor(medications): move the dose correction and undo out of their route The PUT handler was one 390-line callback. The route keeps the record resolver, the 404, the body and its schema refusal; the correction moves verbatim into lib/medications/update-intake-event.ts, split along its stages in the order they always ran: the start-day floor, the slot attribution, the in-place edit or the move onto another slot, then the audit row, the event and the tails. The undo moves into delete-intake-event.ts. The route is 124 lines; no function in either module is over 140. The parity walk recorded before the move (medication-intake-event.parity) is unchanged. The sharing guard's audit, tombstone and overwrite legs read the route together with the modules it hands its bodies to, listed in one map that each route must still import, and the moved-dose provenance leg now also asserts the original source is carried. --- src/__tests__/sharing-surface-guard.test.ts | 101 +++- .../[id]/intake/[eventId]/route.ts | 453 ++-------------- src/lib/medications/delete-intake-event.ts | 115 ++++ src/lib/medications/update-intake-event.ts | 503 ++++++++++++++++++ 4 files changed, 734 insertions(+), 438 deletions(-) create mode 100644 src/lib/medications/delete-intake-event.ts create mode 100644 src/lib/medications/update-intake-event.ts diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 863e0f139..35cfef028 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -421,6 +421,50 @@ function callsSymbol(rel: string, symbol: string): boolean { return callSites(rel).some((c) => c.name === symbol); } +/** + * Route modules whose handler bodies live in service modules under `lib/`. + * + * The route keeps the resolver call, so the verb and membership legs read the + * route alone. The legs about what a handler writes (the audit trail, the + * tombstone, the overwrite and destruction details, the provenance a moved + * dose keeps) ask the route AND the modules it hands the body to, because + * that is where the rows are written now. A leg reading only the route would + * go on passing for a reason that has nothing to do with the rule. Each + * listed 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/[id]/intake/[eventId]/route.ts": [ + "lib/medications/update-intake-event.ts", + "lib/medications/delete-intake-event.ts", + ], +}; + +/** The route and the service modules carrying its handler bodies. */ +function handlerModules(rel: string): string[] { + return [rel, ...(HANDLER_BODY_MODULES[rel] ?? [])]; +} + +function handlerCallsSymbol(rel: string, symbol: string): boolean { + return handlerModules(rel).some((m) => callsSymbol(m, symbol)); +} + +function handlerIdentifiers(rel: string): Set { + return new Set(handlerModules(rel).flatMap((m) => [...identifiers(m)])); +} + +function handlerAuditDetailKeys(rel: string): string[][] { + return handlerModules(rel).flatMap((m) => auditDetailKeysIn(m)); +} + +function handlerDirectAuditWrites(rel: string): string[] { + return handlerModules(rel).flatMap((m) => directAuditWritesIn(read(m), m)); +} + +function handlerText(rel: string): string { + return handlerModules(rel).map(read).join("\n"); +} + /** * v1.37.0 — the exported HTTP verbs whose bodies reach the record resolver at * a given level. @@ -2890,9 +2934,10 @@ describe("(e) the delegable write set is frozen", () => { expect(modules.length).toBeGreaterThan(0); for (const rel of modules) { - expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe( - true, - ); + expect( + handlerCallsSymbol(rel, "auditLog"), + `${rel} never calls auditLog`, + ).toBe(true); } }); @@ -2912,7 +2957,7 @@ describe("(e) the delegable write set is frozen", () => { for (const rel of modules) { expect( - directAuditWritesIn(read(rel), rel), + handlerDirectAuditWrites(rel), `${rel} writes an audit row without the helper`, ).toEqual([]); } @@ -3029,6 +3074,28 @@ describe("(g) the MANAGE route set is frozen", () => { expect(discovered).toEqual(expected); }); + it("reads each moved handler body where its route imports it from", () => { + // The map above widens what the audit and tombstone legs read. An entry + // naming a module the route no longer imports would widen it to code the + // handler does not run, so every entry is held to the route's imports, + // and every listed module has to carry an audit call of its own. + const entries = Object.entries(HANDLER_BODY_MODULES); + expect(entries.length).toBeGreaterThan(0); + for (const [route, modules] of entries) { + expect(DELEGABLE_ROUTES, route).toHaveProperty([route]); + for (const body of modules) { + const specifier = `@/${body.replace(/\.ts$/, "")}`; + expect(read(route), `${route} does not import ${specifier}`).toContain( + `"${specifier}"`, + ); + expect( + callsSymbol(body, "auditLog"), + `${body} writes no audit row`, + ).toBe(true); + } + } + }); + it("is a subset of the delegable set", () => { // A module cannot manage a record it may not read. The read list is where // each file's argument lives, so a manage entry with no read entry would @@ -3057,16 +3124,17 @@ describe("(g) the MANAGE route set is frozen", () => { expect(mutating.length).toBeGreaterThan(30); for (const rel of mutating) { - expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe( - true, - ); + expect( + handlerCallsSymbol(rel, "auditLog"), + `${rel} never calls auditLog`, + ).toBe(true); } }); it("no manage module reaches the audit table without the helper", () => { for (const rel of Object.keys(DELEGABLE_MANAGE_ROUTES)) { expect( - directAuditWritesIn(read(rel), rel), + handlerDirectAuditWrites(rel), `${rel} writes an audit row without the helper`, ).toEqual([]); } @@ -3085,7 +3153,7 @@ describe("(g) the MANAGE route set is frozen", () => { for (const rel of destructive) { const entry = DELEGABLE_MANAGE_ROUTES[rel]; - const tombstones = identifiers(rel).has("deletedAt"); + const tombstones = handlerIdentifiers(rel).has("deletedAt"); expect( tombstones || entry.conditions.includes("C3") || @@ -3106,7 +3174,7 @@ describe("(g) the MANAGE route set is frozen", () => { for (const [rel, { keys, why }] of members) { expect(why.trim().length, `${rel} has no reason`).toBeGreaterThan(0); expect(DELEGABLE_MANAGE_ROUTES, rel).toHaveProperty([rel]); - const carried = auditDetailKeysIn(rel); + const carried = handlerAuditDetailKeys(rel); expect(carried.length, `${rel} files no audit details`).toBeGreaterThan( 0, ); @@ -3129,10 +3197,10 @@ describe("(g) the MANAGE route set is frozen", () => { for (const rel of members) { if (rel in MANAGE_RECONSTRUCTION_BY_HAND) continue; expect( - callsSymbol(rel, "destroyedDetails"), + handlerCallsSymbol(rel, "destroyedDetails"), `${rel} carries C3 and never calls destroyedDetails`, ).toBe(true); - const named = auditDetailKeysIn(rel).some((keys) => + const named = handlerAuditDetailKeys(rel).some((keys) => keys.some((key) => /(^id$|Id$)/.test(key)), ); expect(named, `${rel} files no id on any audit details literal`).toBe( @@ -3147,7 +3215,7 @@ describe("(g) the MANAGE route set is frozen", () => { for (const rel of members) { expect( - callsSymbol(rel, "overwriteDetails"), + handlerCallsSymbol(rel, "overwriteDetails"), `${rel} carries C4 and never calls overwriteDetails`, ).toBe(true); } @@ -3328,7 +3396,7 @@ describe("(g) the MANAGE route set is frozen", () => { const medication = "app/api/medications/[id]/route.ts"; expect(manageModulesWith("C7")).toEqual([medication]); - const cadence = auditDetailKeysIn(medication).some( + const cadence = handlerAuditDetailKeys(medication).some( (keys) => keys.includes("replacedCadence") && keys.includes("tombstonedSlotCount"), @@ -3339,14 +3407,15 @@ describe("(g) the MANAGE route set is frozen", () => { expect(manageModulesWith("C8")).toEqual([dose]); // The hardcoded provenance this condition removed. A re-created row that // stamps `WEB` says a human typed a dose that arrived from a device. - expect(read(dose)).not.toContain('createSource: "WEB"'); + expect(handlerText(dose)).not.toContain('createSource: "WEB"'); + expect(handlerText(dose)).toContain("createSource: event.source"); expect(manageModulesWith("C9")).toEqual([ "app/api/medications/[id]/intake/import/route.ts", "app/api/medications/intake/dose-history-import/route.ts", ]); for (const rel of manageModulesWith("C9")) { - const joined = auditDetailKeysIn(rel).some((keys) => + const joined = handlerAuditDetailKeys(rel).some((keys) => keys.includes("jobId"), ); expect(joined, `${rel} enqueues work no audit row names`).toBe(true); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 4eaafb54d..3e5c5cd59 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -1,8 +1,6 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -13,22 +11,14 @@ import { } from "@/lib/api-response"; import { updateIntakeEventSchema } from "@/lib/validations/medication"; 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 { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; -import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; + findEditableIntakeEvent, + updateIntakeEvent, +} from "@/lib/medications/update-intake-event"; import { - applyCanonicalSlotWrite, - findPinConflict, - resolveForcedSlotForWrite, - resolveSlotForWriteByBand, -} from "@/lib/medications/scheduling/slot-upsert"; + deleteIntakeEvent, + findDeletableIntakeEvent, +} from "@/lib/medications/delete-intake-event"; import { NextRequest } from "next/server"; -import { dateOnlyKey } from "@/lib/tz/date-only"; type RouteParams = { params: Promise<{ id: string; eventId: string }> }; @@ -41,13 +31,12 @@ export const PUT = apiHandler( const { id, eventId } = await params; - // v1.7.0 sync — a tombstoned event 404s on PUT; the `deletedAt: null` - // filter refuses to resurrect-edit a soft-deleted intake. - const event = await prisma.medicationIntakeEvent.findFirst({ - where: { id: eventId, deletedAt: null }, + const event = await findEditableIntakeEvent({ + userId: user.id, + medicationId: id, + eventId, }); - - if (!event || event.userId !== user.id || event.medicationId !== id) { + if (!event) { return apiError("Intake not found", 404); } @@ -87,341 +76,20 @@ export const PUT = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const data = parsed.data; - - // v1.15.19 — start-date guard on an edited `takenAt` (audit P0-4). The - // schema bounds the instant to a plausible absolute range; this check - // adds the per-medication floor: a take cannot predate the day the - // medication starts (calendar-day comparison in the user's timezone, so - // a take in the early hours of the start day never false-rejects). - if (data.takenAt instanceof Date) { - const medication = await prisma.medication.findFirst({ - where: { id, userId: user.id }, - select: { startsOn: true }, - }); - if (medication?.startsOn) { - const takenDay = dayKeyForUserTz(data.takenAt, user.timezone); - const startsOnDay = dateOnlyKey(medication.startsOn); - if (takenDay < startsOnDay) { - annotate({ - action: { - name: "medications.intake.event.update.before-start-date", - }, - meta: { medication_id: id, event_id: eventId }, - }); - return apiError( - "takenAt is before the medication's start date", - 422, - { errorCode: "medications.intake.taken_at.before_start" }, - ); - } - } - } - - // The post-edit state of the row (current values where the body omits a - // field) — drives the re-attribution decision. - const nextTakenAt = - data.takenAt !== undefined ? data.takenAt : event.takenAt; - const nextSkipped = - data.skipped !== undefined ? data.skipped : event.skipped; - const takenOrSkippedChanged = - (data.takenAt !== undefined && - data.takenAt?.getTime() !== event.takenAt?.getTime()) || - (data.skipped !== undefined && data.skipped !== event.skipped); - - // v1.15.18 — re-run window-band attribution when the edit moves `takenAt` - // or `skipped`, so an edited time re-binds to the correct slot instead of - // leaving `scheduledFor` stale (audit HIGH-4). Precedence: - // 1. an explicit `scheduledFor` in the body wins (deliberate override); - // 2. else `forceSlotInstant` pins onto a named real slot (422 if it is - // not a slot of this med) — v1.15.20: standalone too, no takenAt / - // skipped change required ("Slot zuordnen" from the ledger kebab), - // and the row is stamped USER_PIN so the read ledger binds it by - // anchor instead of degrading the off-window take back to ad-hoc; - // 3. else an explicit `forceSlotInstant: null` UNPINS (v1.15.20, - // "Zuordnung lösen"): re-attribute the take by band on its takenAt - // (ad-hoc when no band matches). v1.16.0 — the release is itself a - // deliberate binding decision, so the row KEEPS `USER_PIN` - // provenance ("attribution user-fixed": onto a slot OR deliberately - // ad-hoc). The persisted marker is what keeps the nightly slot - // dedup from snapping the released row back into the cluster the - // user just detached it from; - // 4. else a taken edit attributes by band (slot anchor on match, the - // take's own time on a miss → ad-hoc), provenance AUTO; - // 5. a skip / pending edit keeps the existing `scheduledFor` anchor. - let resolvedScheduledFor: Date | undefined = data.scheduledFor; - let attributionSource: "AUTO" | "USER_PIN" | undefined; - if (resolvedScheduledFor === undefined) { - if ( - data.forceSlotInstant !== undefined && - data.forceSlotInstant !== null - ) { - const forced = await resolveForcedSlotForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - slotInstant: data.forceSlotInstant, - }); - if (forced === null) { - annotate({ - action: { name: "medication.intake.force_slot.invalid" }, - meta: { medication_id: id, event_id: eventId }, - }); - 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 (excluding the row being edited): 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: forced, - incomingTakenAt: nextSkipped ? null : nextTakenAt, - excludeEventId: eventId, - }) - ) { - annotate({ - action: { name: "medication.intake.force_slot.occupied" }, - meta: { medication_id: id, event_id: eventId }, - }); - return apiError( - "forceSlotInstant already carries a recorded dose action", - 422, - { errorCode: "medications.intake.force_slot.occupied" }, - ); - } - resolvedScheduledFor = forced; - attributionSource = "USER_PIN"; - } else if ( - (data.forceSlotInstant === null || takenOrSkippedChanged) && - nextTakenAt !== null && - !nextSkipped - ) { - const attribution = await resolveSlotForWriteByBand({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - takenAt: nextTakenAt, - }); - // band.at on match; the take's own time on a miss (ad-hoc). - resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt; - // v1.16.0 — an explicit unpin stays user-fixed (USER_PIN) even on - // the released row; a plain taken/skipped edit re-attributes AUTO. - attributionSource = - data.forceSlotInstant === null ? "USER_PIN" : "AUTO"; - } else if (data.forceSlotInstant === null) { - // Unpin on a row that is not a confirmed take (skip / pending): - // nothing to re-attribute, but the provenance still resets — a - // skip / pending row anchors on its slot by construction, so there - // is no user-fixed ad-hoc decision to persist. - attributionSource = "AUTO"; - } - // skip / pending edits leave `scheduledFor` on the existing anchor. - } - - const targetScheduledFor = resolvedScheduledFor ?? event.scheduledFor; - const slotMoved = - targetScheduledFor.getTime() !== event.scheduledFor.getTime(); - - // v1.16.10 — transition gate for the inventory hooks below. Only an - // edit moving the row INTO taken may consume; a row that was - // already taken keeps its stamp frozen (a stamped row would no-op - // anyway, and a pre-v1.16.10 taken row — NULL stamp, stock already - // moved by the legacy hook at take time — must never retro-consume - // on a time correction). - const wasTaken = event.takenAt !== null && !event.skipped; - - let updated; - if (!slotMoved) { - // The slot is unchanged — a bare in-place update can never collide with - // a sibling row (it is its own slot). - updated = await prisma.medicationIntakeEvent.update({ - where: { id: eventId }, - data: { - ...(data.takenAt !== undefined && { takenAt: data.takenAt }), - ...(data.skipped !== undefined && { skipped: data.skipped }), - // v1.15.20 — binding provenance: pin / unpin / band re-attribution - // stamps it even when the anchor itself did not move (e.g. a pin - // onto the slot the row already sits on, or an unpin whose band - // re-attribution lands on the same anchor). - ...(attributionSource !== undefined && { attributionSource }), - // v1.42.2 — an edit of the outcome is the person's own record. - ...((data.takenAt !== undefined || data.skipped !== undefined) && { - autoRecorded: false, - }), - // v1.7.0 sync — bump the reconciliation counter on every - // server-side mutation so the `/api/sync/changes` feed echoes a - // monotonic value to paired clients. - syncVersion: { increment: 1 }, - }, - }); - // v1.16.10 — inventory follows the post-edit state: an edit that - // moves the row INTO taken consumes; a row that stays taken keeps - // its stamp untouched (the stamp freezes what the original take - // pulled, even when `unitsPerDose` changed since, and a pre-stamp - // legacy row must not retro-consume); a row edited out of taken - // (skip flip, takenAt cleared) refunds its stamp. - if (nextTakenAt !== null && !nextSkipped) { - if (!wasTaken) { - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: id, - eventId, - intakeAt: nextTakenAt, - }); - } - } else { - await restoreForIntake({ - client: prisma, - userId: user.id, - eventId, - }); - } - } else { - // The edit moved the dose to a different slot. Tombstone the original - // row (the iOS "a correction is a tombstone + re-insert" model the - // DELETE handler documents) and route the corrected dose through the - // shared canonical-slot upsert, which converges onto any row already at - // the target slot (a pending REMINDER row) rather than bare-updating - // into an occupied slot and risking a P2002 (audit HIGH-4). - // - // v1.16.10 — refund the source row's consumption stamp BEFORE the - // tombstone; the consume on the converged row below nets the move - // to exactly one consumption. - await restoreForIntake({ - client: prisma, - userId: user.id, - eventId, - }); - await prisma.medicationIntakeEvent.update({ - where: { id: eventId }, - data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, - }); - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId: user.id, - medicationId: id, - canonicalSlot: targetScheduledFor, - takenAt: nextSkipped ? null : nextTakenAt, - skipped: nextSkipped, - isExplicitTaken: !nextSkipped && nextTakenAt !== null, - isExplicitSkip: nextSkipped, - idempotencyKey: null, - // The converged row keeps the ORIGINAL's provenance. This used - // to be a hardcoded `"WEB"`, which said "somebody typed this in the - // browser" about an Apple Health dose whose time was corrected. Under - // MANAGE the sentence would be wrong about a second person as well. - createSource: event.source, - attributionSource, - // v1.16.9 — a slot move is a re-binding, not a new dose: the - // original row's recorded injection site and dose override ride - // along onto the converged row instead of being tombstoned away. - injectionSite: event.injectionSite, - doseTaken: event.doseTaken, - }); - updated = applied.row; - // A taken source WITHOUT a stamp is a pre-v1.16.10 row whose - // stock moved through the legacy hook: the refund above was a - // no-op, so consuming on the converged row would double-charge — - // skip it. Every other taken outcome nets the move to exactly one - // consumption. - if ( - nextTakenAt !== null && - !nextSkipped && - (!wasTaken || event.inventoryConsumption !== null) - ) { - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: id, - eventId: applied.row.id, - intakeAt: nextTakenAt, - }); - } - } - - await auditLog("medication.intake.update", { - userId: user.id, - ipAddress: getClientIp(request), - // What the correction moved. A dose edit changes when a dose is - // recorded as taken, which is exactly what a clinician reads back. - details: { - eventId, - medicationId: id, - ...overwriteDetails({ - before: { - scheduledFor: event.scheduledFor, - takenAt: event.takenAt, - skipped: event.skipped, - }, - // The slot-write result carries the canonical four. The injection - // site and the dose override are not compared here because the move - // path copies them onto the converged row by construction — there is - // no edit of them to report. - after: { - scheduledFor: updated.scheduledFor, - takenAt: updated.takenAt, - skipped: updated.skipped, - }, - }), - }, - }); - - annotate({ - action: { - name: "medication.intake.update", - entity_type: "intake_event", - entity_id: eventId, - }, - meta: { medication_id: id }, - }); - - // v1.4.34 — bust per-user medications + compliance + achievement - // caches so the next read reflects the edited event. - invalidateUserMedications(user.id, { evict: true }); - - // v1.4.39 — refresh the rollup row for both the old + new - // scheduledFor day-keys when the dose moved across a day boundary - // (band re-attribution can move it as well as an explicit edit); if the - // slot is unchanged the second call collapses to a no-op upsert. - await refreshDoseSlot({ - userId: user.id, + const outcome = await updateIntakeEvent({ + user, medicationId: id, - scheduledFor: event.scheduledFor, - tz: user.timezone, - }); - if (slotMoved) { - await refreshDoseSlot({ - userId: user.id, - medicationId: id, - scheduledFor: targetScheduledFor, - tz: user.timezone, - }); - } - - // v1.5.0 — re-evaluate the one-shot active flag. A skip-flip on the - // single live intake of a one-shot medication should reactivate it - // (the dose is no longer logged), and the reverse case should - // deactivate again. No-op for non-one-shot medications. - await reconcileOneShotState(prisma, id, user.id); - - // (#22) — silent cross-device intake sync: an edited dose must - // reconcile on the user's other iOS devices too. Coalesced per user, - // best-effort — never affects the response. - queueMedicationIntakeSync({ - userId: user.id, + eventId, + event, + data: parsed.data, + ipAddress: getClientIp(request), originDeviceToken: request.headers.get("x-device-id"), }); + if (!outcome.ok) { + return apiError(outcome.message, 422, { errorCode: outcome.errorCode }); + } - return apiSuccess(updated); + return apiSuccess(outcome.row); }, ); @@ -433,80 +101,21 @@ export const DELETE = apiHandler( const { id, eventId } = await params; - const event = await prisma.medicationIntakeEvent.findUnique({ - where: { id: eventId }, + const event = await findDeletableIntakeEvent({ + userId: user.id, + medicationId: id, + eventId, }); - - if (!event || event.userId !== user.id || event.medicationId !== id) { + if (!event) { return apiError("Intake not found", 404); } - // v1.16.10 — a deleted dose record refunds its consumption stamp - // (no-op for a never-consumed row; a re-delete finds it cleared). - await restoreForIntake({ - client: prisma, - userId: user.id, - eventId, - }); - - // v1.7.0 sync — soft-delete instead of a hard `delete`. An intake is - // an immutable fact, so a "correction" is a tombstone + re-insert - // (never an in-place edit). Setting `deletedAt` (+ bumping - // `syncVersion`) leaves the row in place so the `/api/sync/changes` - // feed surfaces the deletion as a tombstone keyed on the server `id` - // to paired clients offline at delete time. Every today / compliance - // / list read filters `deletedAt: null`, so the row is invisible to - // normal reads from here on. A re-delete re-bumps harmlessly. - await prisma.medicationIntakeEvent.update({ - where: { id: eventId }, - data: { - deletedAt: new Date(), - syncVersion: { increment: 1 }, - }, - }); - - const ip = getClientIp(request) ?? "unknown"; - await auditLog("medication.intake.delete", { - userId: user.id, - ipAddress: ip, - details: { eventId, medicationId: id }, - }); - - annotate({ - action: { - name: "medication.intake.delete", - entity_type: "intake_event", - entity_id: eventId, - }, - meta: { medication_id: id }, - }); - - // v1.4.34 — bust per-user medications + compliance + achievement - // caches so the next read reflects the deletion. - invalidateUserMedications(user.id, { evict: true }); - - // v1.4.39 — refresh the rollup row for the day the deleted - // event sat on. When that day now holds zero events the helper - // drops the rollup row entirely. - await refreshDoseSlot({ - userId: user.id, + await deleteIntakeEvent({ + user, medicationId: id, - scheduledFor: event.scheduledFor, - tz: user.timezone, - }); - - // v1.5.0 — re-evaluate the one-shot active flag. Deleting the - // single live intake of a one-shot medication reactivates it so - // the dashboard / lists / worker pick it back up. No-op for - // non-one-shot medications. - await reconcileOneShotState(prisma, id, user.id); - - // (#22) — silent cross-device intake sync: an undone dose must - // reconcile on the user's other iOS devices (the widget count and a - // pending Live Activity both change). Coalesced per user, - // best-effort — never affects the response. - queueMedicationIntakeSync({ - userId: user.id, + eventId, + event, + ipAddress: getClientIp(request) ?? "unknown", originDeviceToken: request.headers.get("x-device-id"), }); diff --git a/src/lib/medications/delete-intake-event.ts b/src/lib/medications/delete-intake-event.ts new file mode 100644 index 000000000..1a3c4b96f --- /dev/null +++ b/src/lib/medications/delete-intake-event.ts @@ -0,0 +1,115 @@ +/** + * Undo one recorded dose: `DELETE /api/medications/{id}/intake/{eventId}`. + * + * A soft delete: the row stays, the sync feed carries the tombstone, and the + * inventory stamp is refunded. The route authenticates (MANAGE over the + * `medications` section) and answers the 404; the rest runs here. + */ +import type { MedicationIntakeEvent } from "@/generated/prisma/client"; +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 { 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 { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; + +/** + * The event under this medication and record, tombstoned or not (a re-delete + * re-bumps harmlessly), or null for the route's 404. + */ +export async function findDeletableIntakeEvent(input: { + userId: string; + medicationId: string; + eventId: string; +}): Promise { + const event = await prisma.medicationIntakeEvent.findUnique({ + where: { id: input.eventId }, + }); + if ( + !event || + event.userId !== input.userId || + event.medicationId !== input.medicationId + ) { + return null; + } + return event; +} + +export async function deleteIntakeEvent(input: { + user: Pick; + medicationId: string; + eventId: string; + event: MedicationIntakeEvent; + ipAddress: string; + originDeviceToken: string | null; +}): Promise { + const { user, medicationId: id, eventId, event } = input; + + // A deleted dose record refunds its consumption stamp (no-op for a + // never-consumed row; a re-delete finds it cleared). + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); + + // Sync — soft-delete instead of a hard `delete`. An intake is an immutable + // fact, so a "correction" is a tombstone + re-insert (never an in-place + // edit). Setting `deletedAt` (+ bumping `syncVersion`) leaves the row in + // place so the `/api/sync/changes` feed surfaces the deletion as a + // tombstone keyed on the server `id` to paired clients offline at delete + // time. Every today / compliance / list read filters `deletedAt: null`, so + // the row is invisible to normal reads from here on. A re-delete re-bumps + // harmlessly. + await prisma.medicationIntakeEvent.update({ + where: { id: eventId }, + data: { + deletedAt: new Date(), + syncVersion: { increment: 1 }, + }, + }); + + await auditLog("medication.intake.delete", { + userId: user.id, + ipAddress: input.ipAddress, + details: { eventId, medicationId: id }, + }); + + annotate({ + action: { + name: "medication.intake.delete", + entity_type: "intake_event", + entity_id: eventId, + }, + meta: { medication_id: id }, + }); + + // Bust per-user medications + compliance + achievement caches so the next + // read reflects the deletion. + invalidateUserMedications(user.id, { evict: true }); + + // Refresh the rows derived from the day the deleted event sat on. + await refreshDoseSlot({ + userId: user.id, + medicationId: id, + scheduledFor: event.scheduledFor, + tz: user.timezone, + }); + + // Re-evaluate the one-shot active flag. Deleting the single live intake of + // a one-shot medication reactivates it so the dashboard / lists / worker + // pick it back up. No-op for non-one-shot medications. + await reconcileOneShotState(prisma, id, user.id); + + // (#22) — silent cross-device intake sync: an undone dose must reconcile on + // the user's other iOS devices (the widget count and a pending Live + // Activity both change). Coalesced per user, best-effort — never affects + // the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: input.originDeviceToken, + }); +} diff --git a/src/lib/medications/update-intake-event.ts b/src/lib/medications/update-intake-event.ts new file mode 100644 index 000000000..2e53b0900 --- /dev/null +++ b/src/lib/medications/update-intake-event.ts @@ -0,0 +1,503 @@ +/** + * Correct one recorded dose: `PUT /api/medications/{id}/intake/{eventId}`. + * + * The route authenticates (MANAGE over the `medications` section), finds the + * live event, and parses the body; everything from the start-date floor to the + * cross-device sync tail runs here, in the order it always ran. A correction + * that keeps the dose on its slot edits the row in place; one that moves it + * tombstones the original and converges onto the target slot through the + * shared canonical-slot write. + */ +import type { MedicationIntakeEvent } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; +import type { SessionUser } from "@/lib/auth/session-user"; +import type { updateIntakeEventSchema } from "@/lib/validations/medication"; +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 { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { + applyCanonicalSlotWrite, + findPinConflict, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import type { z } from "zod/v4"; + +export type IntakeEventEdit = z.output; + +/** A coded 422 refusal the route answers with `apiError`. */ +export interface IntakeEditRefusal { + ok: false; + message: string; + errorCode: string; +} + +type AttributionSource = "AUTO" | "USER_PIN"; + +interface EditContext { + user: Pick; + medicationId: string; + eventId: string; + event: MedicationIntakeEvent; +} + +/** + * The live event a correction may edit, or null for the route's 404. A + * tombstoned event 404s on PUT; the `deletedAt: null` filter refuses to + * resurrect-edit a soft-deleted intake. + */ +export async function findEditableIntakeEvent(input: { + userId: string; + medicationId: string; + eventId: string; +}): Promise { + const event = await prisma.medicationIntakeEvent.findFirst({ + where: { id: input.eventId, deletedAt: null }, + }); + if ( + !event || + event.userId !== input.userId || + event.medicationId !== input.medicationId + ) { + return null; + } + return event; +} + +/** + * Start-date guard on an edited `takenAt` (audit P0-4). The schema bounds the + * instant to a plausible absolute range; this check adds the per-medication + * floor: a take cannot predate the day the medication starts (calendar-day + * comparison in the user's timezone, so a take in the early hours of the + * start day never false-rejects). + */ +async function refuseTakeBeforeStart( + ctx: EditContext, + data: IntakeEventEdit, +): Promise { + const { user, medicationId: id, eventId } = ctx; + if (data.takenAt instanceof Date) { + const medication = await prisma.medication.findFirst({ + where: { id, userId: user.id }, + select: { startsOn: true }, + }); + if (medication?.startsOn) { + const takenDay = dayKeyForUserTz(data.takenAt, user.timezone); + const startsOnDay = dateOnlyKey(medication.startsOn); + if (takenDay < startsOnDay) { + annotate({ + action: { + name: "medications.intake.event.update.before-start-date", + }, + meta: { medication_id: id, event_id: eventId }, + }); + return { + ok: false, + message: "takenAt is before the medication's start date", + errorCode: "medications.intake.taken_at.before_start", + }; + } + } + } + return null; +} + +/** + * Re-run window-band attribution when the edit moves `takenAt` or `skipped`, + * so an edited time re-binds to the correct slot instead of leaving + * `scheduledFor` stale (audit HIGH-4). Precedence: + * 1. an explicit `scheduledFor` in the body wins (deliberate override); + * 2. else `forceSlotInstant` pins onto a named real slot (422 if it is + * not a slot of this med), standalone too, no takenAt / skipped change + * required ("Slot zuordnen" from the ledger kebab), and the row is + * stamped USER_PIN so the read ledger binds it by anchor instead of + * degrading the off-window take back to ad-hoc; + * 3. else an explicit `forceSlotInstant: null` UNPINS ("Zuordnung lösen"): + * re-attribute the take by band on its takenAt (ad-hoc when no band + * matches). The release is itself a deliberate binding decision, so the + * row KEEPS `USER_PIN` provenance ("attribution user-fixed": onto a slot + * OR deliberately ad-hoc). The persisted marker is what keeps the + * nightly slot dedup from snapping the released row back into the + * cluster the user just detached it from; + * 4. else a taken edit attributes by band (slot anchor on match, the + * take's own time on a miss → ad-hoc), provenance AUTO; + * 5. a skip / pending edit keeps the existing `scheduledFor` anchor. + */ +async function resolveEditAttribution( + ctx: EditContext, + data: IntakeEventEdit, + next: { + nextTakenAt: Date | null; + nextSkipped: boolean; + takenOrSkippedChanged: boolean; + }, +): Promise< + | IntakeEditRefusal + | { + ok: true; + resolvedScheduledFor: Date | undefined; + attributionSource: AttributionSource | undefined; + } +> { + const { user, medicationId: id, eventId } = ctx; + const { nextTakenAt, nextSkipped, takenOrSkippedChanged } = next; + let resolvedScheduledFor: Date | undefined = data.scheduledFor; + let attributionSource: AttributionSource | undefined; + if (resolvedScheduledFor === undefined) { + if (data.forceSlotInstant !== undefined && data.forceSlotInstant !== null) { + const forced = await resolveForcedSlotForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + slotInstant: data.forceSlotInstant, + }); + if (forced === null) { + annotate({ + action: { name: "medication.intake.force_slot.invalid" }, + meta: { medication_id: id, event_id: eventId }, + }); + return { + ok: false, + 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 + // (excluding the row being edited): 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: forced, + incomingTakenAt: nextSkipped ? null : nextTakenAt, + excludeEventId: eventId, + }) + ) { + annotate({ + action: { name: "medication.intake.force_slot.occupied" }, + meta: { medication_id: id, event_id: eventId }, + }); + return { + ok: false, + message: "forceSlotInstant already carries a recorded dose action", + errorCode: "medications.intake.force_slot.occupied", + }; + } + resolvedScheduledFor = forced; + attributionSource = "USER_PIN"; + } else if ( + (data.forceSlotInstant === null || takenOrSkippedChanged) && + nextTakenAt !== null && + !nextSkipped + ) { + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: nextTakenAt, + }); + // band.at on match; the take's own time on a miss (ad-hoc). + resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt; + // An explicit unpin stays user-fixed (USER_PIN) even on the released + // row; a plain taken/skipped edit re-attributes AUTO. + attributionSource = data.forceSlotInstant === null ? "USER_PIN" : "AUTO"; + } else if (data.forceSlotInstant === null) { + // Unpin on a row that is not a confirmed take (skip / pending): + // nothing to re-attribute, but the provenance still resets — a + // skip / pending row anchors on its slot by construction, so there + // is no user-fixed ad-hoc decision to persist. + attributionSource = "AUTO"; + } + // skip / pending edits leave `scheduledFor` on the existing anchor. + } + return { ok: true, resolvedScheduledFor, attributionSource }; +} + +/** + * The slot is unchanged — a bare in-place update can never collide with a + * sibling row (it is its own slot). + */ +async function editIntakeInPlace( + ctx: EditContext, + data: IntakeEventEdit, + edit: { + attributionSource: AttributionSource | undefined; + nextTakenAt: Date | null; + nextSkipped: boolean; + wasTaken: boolean; + }, +) { + const { user, medicationId: id, eventId } = ctx; + const { attributionSource, nextTakenAt, nextSkipped, wasTaken } = edit; + const updated = await prisma.medicationIntakeEvent.update({ + where: { id: eventId }, + data: { + ...(data.takenAt !== undefined && { takenAt: data.takenAt }), + ...(data.skipped !== undefined && { skipped: data.skipped }), + // Binding provenance: pin / unpin / band re-attribution stamps it even + // when the anchor itself did not move (e.g. a pin onto the slot the row + // already sits on, or an unpin whose band re-attribution lands on the + // same anchor). + ...(attributionSource !== undefined && { attributionSource }), + // An edit of the outcome is the person's own record. + ...((data.takenAt !== undefined || data.skipped !== undefined) && { + autoRecorded: false, + }), + // Sync — bump the reconciliation counter on every server-side mutation + // so the `/api/sync/changes` feed echoes a monotonic value to paired + // clients. + syncVersion: { increment: 1 }, + }, + }); + // Inventory follows the post-edit state: an edit that moves the row INTO + // taken consumes; a row that stays taken keeps its stamp untouched (the + // stamp freezes what the original take pulled, even when `unitsPerDose` + // changed since, and a pre-stamp legacy row must not retro-consume); a row + // edited out of taken (skip flip, takenAt cleared) refunds its stamp. + if (nextTakenAt !== null && !nextSkipped) { + if (!wasTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId, + intakeAt: nextTakenAt, + }); + } + } else { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); + } + return updated; +} + +/** + * The edit moved the dose to a different slot. Tombstone the original row + * (the iOS "a correction is a tombstone + re-insert" model the DELETE handler + * documents) and route the corrected dose through the shared canonical-slot + * upsert, which converges onto any row already at the target slot (a pending + * REMINDER row) rather than bare-updating into an occupied slot and risking a + * P2002 (audit HIGH-4). + * + * The source row's consumption stamp is refunded BEFORE the tombstone; the + * consume on the converged row below nets the move to exactly one + * consumption. + */ +async function moveIntakeToSlot( + ctx: EditContext, + move: { + targetScheduledFor: Date; + attributionSource: AttributionSource | undefined; + nextTakenAt: Date | null; + nextSkipped: boolean; + wasTaken: boolean; + }, +) { + const { user, medicationId: id, eventId, event } = ctx; + const { + targetScheduledFor, + attributionSource, + nextTakenAt, + nextSkipped, + wasTaken, + } = move; + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); + await prisma.medicationIntakeEvent.update({ + where: { id: eventId }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId: user.id, + medicationId: id, + canonicalSlot: targetScheduledFor, + takenAt: nextSkipped ? null : nextTakenAt, + skipped: nextSkipped, + isExplicitTaken: !nextSkipped && nextTakenAt !== null, + isExplicitSkip: nextSkipped, + idempotencyKey: null, + // The converged row keeps the ORIGINAL's provenance. This used to be a + // hardcoded `"WEB"`, which said "somebody typed this in the browser" + // about an Apple Health dose whose time was corrected. Under MANAGE the + // sentence would be wrong about a second person as well. + createSource: event.source, + attributionSource, + // A slot move is a re-binding, not a new dose: the original row's + // recorded injection site and dose override ride along onto the + // converged row instead of being tombstoned away. + injectionSite: event.injectionSite, + doseTaken: event.doseTaken, + }); + // A taken source WITHOUT a stamp is a legacy row whose stock moved through + // the old take-time hook: the refund above was a no-op, so consuming on the + // converged row would double-charge — skip it. Every other taken outcome + // nets the move to exactly one consumption. + if ( + nextTakenAt !== null && + !nextSkipped && + (!wasTaken || event.inventoryConsumption !== null) + ) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId: applied.row.id, + intakeAt: nextTakenAt, + }); + } + return applied.row; +} + +/** Correct one dose; the event is the live row the route found. */ +export async function updateIntakeEvent( + input: EditContext & { + data: IntakeEventEdit; + ipAddress: string | null; + originDeviceToken: string | null; + }, +) { + const { user, medicationId: id, eventId, event, data } = input; + const ctx: EditContext = { user, medicationId: id, eventId, event }; + + const beforeStart = await refuseTakeBeforeStart(ctx, data); + if (beforeStart) return beforeStart; + + // The post-edit state of the row (current values where the body omits a + // field) — drives the re-attribution decision. + const nextTakenAt = data.takenAt !== undefined ? data.takenAt : event.takenAt; + const nextSkipped = data.skipped !== undefined ? data.skipped : event.skipped; + const takenOrSkippedChanged = + (data.takenAt !== undefined && + data.takenAt?.getTime() !== event.takenAt?.getTime()) || + (data.skipped !== undefined && data.skipped !== event.skipped); + + const attribution = await resolveEditAttribution(ctx, data, { + nextTakenAt, + nextSkipped, + takenOrSkippedChanged, + }); + if (!attribution.ok) return attribution; + const { resolvedScheduledFor, attributionSource } = attribution; + + const targetScheduledFor = resolvedScheduledFor ?? event.scheduledFor; + const slotMoved = + targetScheduledFor.getTime() !== event.scheduledFor.getTime(); + + // Transition gate for the inventory hooks. Only an edit moving the row INTO + // taken may consume; a row that was already taken keeps its stamp frozen (a + // stamped row would no-op anyway, and a legacy taken row — NULL stamp, stock + // already moved by the old hook at take time — must never retro-consume on + // a time correction). + const wasTaken = event.takenAt !== null && !event.skipped; + + const updated = !slotMoved + ? await editIntakeInPlace(ctx, data, { + attributionSource, + nextTakenAt, + nextSkipped, + wasTaken, + }) + : await moveIntakeToSlot(ctx, { + targetScheduledFor, + attributionSource, + nextTakenAt, + nextSkipped, + wasTaken, + }); + + await auditLog("medication.intake.update", { + userId: user.id, + ipAddress: input.ipAddress, + // What the correction moved. A dose edit changes when a dose is + // recorded as taken, which is exactly what a clinician reads back. + details: { + eventId, + medicationId: id, + ...overwriteDetails({ + before: { + scheduledFor: event.scheduledFor, + takenAt: event.takenAt, + skipped: event.skipped, + }, + // The slot-write result carries the canonical four. The injection + // site and the dose override are not compared here because the move + // path copies them onto the converged row by construction — there is + // no edit of them to report. + after: { + scheduledFor: updated.scheduledFor, + takenAt: updated.takenAt, + skipped: updated.skipped, + }, + }), + }, + }); + + annotate({ + action: { + name: "medication.intake.update", + entity_type: "intake_event", + entity_id: eventId, + }, + meta: { medication_id: id }, + }); + + // Bust per-user medications + compliance + achievement caches so the next + // read reflects the edited event. + invalidateUserMedications(user.id, { evict: true }); + + // Refresh the rows derived from both the old + new scheduledFor day-keys + // when the dose moved across a day boundary (band re-attribution can move + // it as well as an explicit edit); if the slot is unchanged the second call + // collapses to a no-op upsert. + await refreshDoseSlot({ + userId: user.id, + medicationId: id, + scheduledFor: event.scheduledFor, + tz: user.timezone, + }); + if (slotMoved) { + await refreshDoseSlot({ + userId: user.id, + medicationId: id, + scheduledFor: targetScheduledFor, + tz: user.timezone, + }); + } + + // Re-evaluate the one-shot active flag. A skip-flip on the single live + // intake of a one-shot medication should reactivate it (the dose is no + // longer logged), and the reverse case should deactivate again. No-op for + // non-one-shot medications. + await reconcileOneShotState(prisma, id, user.id); + + // (#22) — silent cross-device intake sync: an edited dose must reconcile on + // the user's other iOS devices too. Coalesced per user, best-effort — never + // affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: input.originDeviceToken, + }); + + return { ok: true as const, row: updated }; +}