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 }; +}