diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 9021ada25..497c146d6 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -74,6 +74,9 @@ const SCOPED_DIRS = [ "lib/medications/delete-medication.ts", "lib/medications/record-intake/", "lib/medications/list-intake-events.ts", + "lib/medications/intake-today.ts", + "lib/medications/intake-compliance.ts", + "lib/medications/update-intake-status/", ]; /** Types a device writes at sampling rate rather than a few times a day. */ diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d910c57a3..65c30785b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -442,6 +442,10 @@ const HANDLER_BODY_MODULES: Record = { "app/api/medications/[id]/intake/route.ts": [ "lib/medications/record-intake/record-intake.ts", ], + "app/api/medications/intake/route.ts": [ + "lib/medications/update-intake-status/admission.ts", + "lib/medications/update-intake-status/update-intake-status.ts", + ], "app/api/medications/[id]/intake/[eventId]/route.ts": [ "lib/medications/update-intake-event.ts", "lib/medications/delete-intake-event.ts", diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 27604ba9d..735ac818c 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -22,44 +22,19 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; -import { - buildScheduleAnchoredComplianceBuckets, - type ScheduleAnchoredComplianceBucket, -} from "@/lib/analytics/schedule-anchored-compliance"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; -import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; -import { - applyCanonicalSlotWrite, - resolveForcedSlotForWrite, - resolveSlotForWriteByBand, -} from "@/lib/medications/scheduling/slot-upsert"; -import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; -import { - consumeForIntake, - restoreForIntake, -} from "@/lib/medications/inventory/consumption"; import { intakeAggregateQuerySchema, intakeStatusUpdateSchema, - type InjectionSiteValue, } from "@/lib/validations/medication"; -import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; -import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; -import { - TRACKED_INTAKE_EVENT_WHERE, - isRecordOnly, -} from "@/lib/medications/intake-tracking"; import { refuseUntrackedIntake } from "@/lib/medications/route-guards"; +import { readTodayIntakes } from "@/lib/medications/intake-today"; +import { readIntakeCompliance } from "@/lib/medications/intake-compliance"; +import { findIntakeForStatusUpdate } from "@/lib/medications/update-intake-status/admission"; +import { updateIntakeStatus } from "@/lib/medications/update-intake-status/update-intake-status"; -// The query + body schemas moved to `@/lib/validations/medication` when this +// The query + body schemas live in `@/lib/validations/medication` since this // endpoint joined the published contract: the OpenAPI registry generates from // the validation modules, and a schema that only exists inside a handler is // one the spec has to restate — which is a second copy to keep in step. @@ -71,22 +46,22 @@ export const GET = apiHandler(async (request: NextRequest) => { Object.fromEntries(request.nextUrl.searchParams), ); if (!parsed.success) { - // v1.4.43 — multi-issue 422 + audit breadcrumb keyed + // Multi-issue 422 + audit breadcrumb keyed // `medications.intake.list.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); annotate({ action: { name: "medications.intake.list.validation-failed" }, meta: { issue_count: issues.length }, }); - // v1.4.49 — strip `message` from the audit-ledger row; iOS-sent - // query strings can flow into Zod issue messages. + // Strip `message` from the audit-ledger row; iOS-sent query strings can + // flow into Zod issue messages. const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only thing that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a delegate's malformed query would read as the owner's own. + // Through `auditLog()` rather than a bare `prisma.auditLog.create`, + // because that helper is the only thing that stamps `actorUserId`. Filed + // under the resolved record either way; without the stamp a delegate's + // malformed query would read as the owner's own. void auditLog("medications.intake.list.validation-failed", { userId: user.id, details: { issues: auditIssues }, @@ -98,117 +73,24 @@ export const GET = apiHandler(async (request: NextRequest) => { const { scope, days } = parsed.data; - // v1.4.25 — anchor "today" and per-day compliance buckets to the - // user's display timezone so a 23:30 reading in Pacific/Auckland lands - // in today's bucket rather than yesterday's Berlin one. + // Anchor "today" and per-day compliance buckets to the user's display + // timezone so a 23:30 reading in Pacific/Auckland lands in today's bucket + // rather than yesterday's Berlin one. const userTz = user.timezone ?? DEFAULT_TIMEZONE; if (scope === "today") { - const todayStart = startOfLocalDayInTz(new Date(), userTz); - const todayEnd = new Date(todayStart.getTime() + 86_400_000); - - // v1.4.39 — project pending intake rows for every - // active schedule whose window opens today, then idempotently - // backfill any missing rows. Pre-fix the endpoint returned `[]` - // for daily meds (`schedule.daysOfWeek = null` in the DB) until - // the reminder worker entered the RED phase at the end of the - // dose window — leaving the iOS Dashboard tile + the "Erfassen" - // sheet empty for the whole morning. Shared helper mirrors the - // dashboard-summary call site so the two routes converge on the - // same row set. - const { projected, backfilled } = await projectTodayIntakesAndRecompute({ - userId: user.id, - userTz, - todayStart, - todayEnd, - }); - - const events = await prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - // v1.7.0 sync — exclude tombstoned rows from the today list. - deletedAt: null, - scheduledFor: { gte: todayStart, lt: todayEnd }, - // v1.39.1 (#1033) — the today list is what clients offer doses - // from; a medication with intake tracking off is not on it. - ...TRACKED_INTAKE_EVENT_WHERE, - }, - orderBy: { scheduledFor: "asc" }, - include: { medication: { select: { id: true, snoozedUntil: true } } }, - }); - - annotate({ - action: { name: "medications.intake.today" }, - meta: { - count: events.length, - projected, - backfilled, - }, - }); - - return apiSuccess( - events.map((e) => ({ - id: e.id, - medicationId: e.medicationId, - scheduledAt: e.scheduledFor.toISOString(), - takenAt: e.takenAt?.toISOString() ?? null, - status: e.skipped - ? "skipped" - : e.takenAt - ? "taken" - : // v1.15.9 — a never-acted dose the auto-miss cron flipped is a - // terminal MISS, not a perpetual "pending". (Today's freshly - // projected rows are < 24 h old so this rarely fires here, but - // a stale row surfacing in the window reads honestly.) - e.autoMissed - ? "missed" - : e.medication.snoozedUntil && - e.medication.snoozedUntil > new Date() - ? "snoozed" - : "pending", - snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null, - })), - ); + return apiSuccess(await readTodayIntakes({ userId: user.id, userTz })); } - // v1.4.34 — compliance: per-day scheduled vs taken for the last - // N days. Cached at a 15-minute TTL because daily compliance buckets - // are slow-moving (intake events trickle in, yesterday's row doesn't - // move). Cache key carries the userTz so a user who changes timezone - // doesn't read another tz's bucketing. - // - // v1.15.9 — `scheduled` is now the SCHEDULE-ANCHORED expected-dose count - // per day (the canonical recurrence engine), NOT the count of logged - // intake rows. The old rollup-backed path set `scheduled = COUNT(*)` of - // intake rows, so the dashboard tile's rate (`taken / scheduled`) was ~100% - // across every window regardless of real adherence — every logged row was - // both numerator and denominator. Anchoring `scheduled` to the schedule - // makes the rate genuinely reflect taken-of-expected and lets the 7/30/90 - // windows diverge with partial adherence. - const result = await cached( - caches.medicationsIntake as ServerCache, - `${user.id}|compliance|${days}|${userTz}`, - () => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz), - annotate, + return apiSuccess( + await readIntakeCompliance({ userId: user.id, days, userTz }), ); - - annotate({ - action: { name: "medications.intake.compliance" }, - meta: { days, count: result.length }, - }); - - return apiSuccess(result); }); export const POST = apiHandler(async (request: NextRequest) => { - // v1.36.x — a delegated write, the canonical slot form. Everything the - // handler does downstream addresses the RECORD and stays correct: the - // cross-device intake sync wakes the OWNER's iOS devices, the web-clear - // closes the OWNER's pending dose reminder and rewrites the OWNER's badge, - // and the compliance rollup recomputes the OWNER's day. None of them can - // address the delegate, and none of them should — the delegate's feedback is - // the response they are already awaiting. What the owner gets instead is the - // notification at the end of this handler. + // A delegated write, the canonical slot form. Everything the handler does + // downstream addresses the RECORD and stays correct; what the owner gets + // is the notification at the end of the update. const { user, actor } = await requireRecordAuth("write", "medications"); const { data: body, error } = await safeJson(request, { @@ -218,18 +100,18 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = intakeStatusUpdateSchema.safeParse(body); if (!parsed.success) { - // v1.4.43 — intake-event update hot path; multi-issue 422 + - // audit breadcrumb keyed `medications.intake.update.validation-failed`. + // Intake-event update hot path; multi-issue 422 + audit breadcrumb keyed + // `medications.intake.update.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); annotate({ action: { name: "medications.intake.update.validation-failed" }, meta: { issue_count: issues.length }, }); - // v1.4.49 — strip `message` from the audit-ledger row. + // Strip `message` from the audit-ledger row. const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - // v1.36.x — through `auditLog()`, the only writer that stamps the actor. + // Through `auditLog()`, the only writer that stamps the actor. void auditLog("medications.intake.update.validation-failed", { userId: user.id, details: { issues: auditIssues }, @@ -239,396 +121,29 @@ export const POST = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { - intakeId, - status, - takenAt, - snoozedUntil, - injectionSite, - forceSlotInstant, - } = parsed.data; - - // v1.7.0 sync — a tombstoned intake 404s on a status toggle; the - // `deletedAt: null` filter refuses to mutate a soft-deleted row. - const existing = await prisma.medicationIntakeEvent.findFirst({ - where: { id: intakeId, deletedAt: null }, - include: { - medication: { - select: { - deliveryForm: true, - trackInjectionSites: true, - allowedInjectionSites: true, - trackIntake: true, - }, - }, - }, + const existing = await findIntakeForStatusUpdate({ + userId: user.id, + intakeId: parsed.data.intakeId, }); - if (!existing || existing.userId !== user.id) { + if (!existing) { return apiError("Intake event not found", 404); } - // A record-only medication keeps the doses it already has, and an owner may - // still correct one of those (that is an edit of history). What it no longer - // takes is a NEW dose: resolving or snoozing an event that is still open - // would record one, and it would then be counted nowhere (iOS #116, item - // 15). Refused by name, the same code the per-medication route answers with. - const stillOpen = existing.takenAt === null && !existing.skipped; - if (stillOpen && isRecordOnly(existing.medication)) { - return refuseUntrackedIntake(); - } - - // v1.36.1 — this route is admitted for delegates because marking a dose is - // the thing a person standing next to the patient needs to do. It is an - // UPDATE, though, and two of the transitions it can express are not that. - // - // Snoozing writes `snoozedUntil` on the OWNER's medication row, and the - // reminder cron skips a medication for as long as that stamp is in the - // future. The field is unbounded, the owner is deliberately not notified of - // a delegated snooze (there is nothing useful to say about a thirty-minute - // deferral), and the activity feed renders the whole family as "marked a - // dose". So a delegate could switch the owner's medication reminders off for - // years, and every surface that exists to make delegation visible would - // describe it as marking a dose. In an adherence product that is the worst - // thing on this route. - // - // Flipping an already-resolved event is the other one: taken to skipped and - // back rewrites the owner's compliance history and refunds inventory. The - // shipped promise, on the consent screen and in the release notes, is that - // editing what is already there stays with the owner. Marking a dose that is - // still open is a contribution; changing a decision the owner already - // recorded is an edit. - // - // Both refuse for a delegate and stay open to the owner, who is the only - // person the snooze and the correction were ever for. - if (actor.id !== user.id) { - const alreadyResolved = existing.takenAt !== null || existing.skipped; - const refusal = - status === "snoozed" - ? "snooze" - : alreadyResolved - ? "resolved_event" - : null; - if (refusal) { - annotate({ - action: { name: "medications.intake.update.refused" }, - meta: { reason: refusal }, - }); - // Filed under the OWNER. `auditLog` stamps the actor itself from the - // request's acting context, which is what puts this on the trail the - // owner's activity panel reads — a refused attempt to change their - // record is worth as much to them as a successful one. - await auditLog("medications.intake.update.refused", { - userId: user.id, - ipAddress: getClientIp(request), - details: { intakeId, status, reason: refusal }, - }); - return apiError( - refusal === "snooze" - ? "Snoozing a reminder is not part of shared access" - : "Changing a dose that is already recorded is not part of shared access", - 403, - { errorCode: "sharing.not_permitted" }, - ); - } - } - - // v1.8.5 — resolve + server-validate the optional injection site for a - // "taken" toggle. A site outside the medication's effective allowed - // set is a hard 422; non-injection / tracking-off / non-taken drops it. - let resolvedInjectionSite: InjectionSiteValue | null = null; - if (injectionSite !== undefined) { - const userRow = await prisma.user.findUnique({ - where: { id: user.id }, - select: { globalExcludedInjectionSites: true }, - }); - const resolution = resolveInjectionSiteForWrite({ - submitted: injectionSite, - taken: status === "taken", - deliveryForm: existing.medication.deliveryForm, - trackInjectionSites: existing.medication.trackInjectionSites, - allowedInjectionSites: existing.medication - .allowedInjectionSites as InjectionSiteValue[], - globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ?? - []) as InjectionSiteValue[], - }); - if (resolution.kind === "disallowed") { - annotate({ - action: { name: "medication.intake.injection_site.disallowed" }, - meta: { medication_id: existing.medicationId, site: resolution.site }, - }); - return apiError( - "Injection site is not allowed for this medication", - 422, - { - errorCode: "medications.intake.injection_site.disallowed", - }, - ); - } - resolvedInjectionSite = resolution.site; - } - - const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE; - - let updated; - if (status === "taken") { - const resolvedTakenAt = takenAt ?? new Date(); - - // v1.15.18 — re-run window-band attribution on a taken toggle so an - // edited / off-window take re-binds to the right slot instead of leaving - // `scheduledFor` stale (audit HIGH-4). `forceSlotInstant` pins onto a - // named real slot (422 if it is not one); otherwise band membership picks - // the slot (the take's own time on a miss → ad-hoc). Mirrors the - // per-event PUT route. - let targetScheduledFor: Date; - if (forceSlotInstant !== undefined) { - const forced = await resolveForcedSlotForWrite({ - userId: user.id, - medicationId: existing.medicationId, - userTz: userTzForHook, - slotInstant: forceSlotInstant, - }); - if (forced === null) { - annotate({ - action: { name: "medication.intake.force_slot.invalid" }, - meta: { medication_id: existing.medicationId, intake_id: intakeId }, - }); - return apiError( - "forceSlotInstant is not a scheduled slot of this medication", - 422, - { errorCode: "medications.intake.force_slot.invalid" }, - ); - } - targetScheduledFor = forced; - } else { - const attribution = await resolveSlotForWriteByBand({ - userId: user.id, - medicationId: existing.medicationId, - userTz: userTzForHook, - takenAt: resolvedTakenAt, - }); - targetScheduledFor = attribution.slotInstant ?? resolvedTakenAt; - } - - const slotMoved = - targetScheduledFor.getTime() !== existing.scheduledFor.getTime(); - - // v1.16.10 — transition gate for the inventory consume below. Only - // a row moving INTO taken may consume; a re-take of an already- - // taken row (e.g. a time correction) leaves its stamp frozen, and a - // pre-v1.16.10 taken row (NULL stamp, stock already moved by the - // legacy hook at take time) must never retro-consume. - const wasTaken = existing.takenAt !== null && !existing.skipped; - if (!slotMoved) { - [updated] = await prisma.$transaction([ - prisma.medicationIntakeEvent.update({ - where: { id: intakeId }, - // v1.7.0 sync — bump the reconciliation counter on every - // server-side mutation so the delta feed echoes a monotonic value. - data: { - takenAt: resolvedTakenAt, - skipped: false, - // v1.42.2 — the person confirmed it; no longer automatic. - autoRecorded: false, - syncVersion: { increment: 1 }, - // v1.8.5 — persist the resolved site on the taken branch. - ...(resolvedInjectionSite !== null && { - injectionSite: resolvedInjectionSite, - }), - }, - }), - prisma.medication.update({ - where: { id: existing.medicationId }, - data: { snoozedUntil: null }, - }), - ]); - // v1.16.10 — the toggle recorded a take; consume inventory units - // on the genuine pending→taken (or skipped→taken) transition. The - // stamp additionally keeps a racing replay exactly-once. - if (!wasTaken) { - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: existing.medicationId, - eventId: intakeId, - intakeAt: resolvedTakenAt, - }); - } - } else { - // The take re-attributed to a different slot. Tombstone the source row - // and route the dose through the shared canonical-slot upsert, which - // converges onto any row already at the target slot rather than - // bare-updating into an occupied slot (P2002-safe). - // - // v1.16.10 — refund the source row's consumption stamp BEFORE the - // tombstone, then consume on the converged row below: a slot move - // is a re-binding of one real dose, so the stock nets exactly one - // consumption. - await restoreForIntake({ - client: prisma, - userId: user.id, - eventId: intakeId, - }); - await prisma.medicationIntakeEvent.update({ - where: { id: intakeId }, - data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, - }); - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId: user.id, - medicationId: existing.medicationId, - canonicalSlot: targetScheduledFor, - takenAt: resolvedTakenAt, - skipped: false, - isExplicitTaken: true, - isExplicitSkip: false, - idempotencyKey: null, - createSource: "WEB", - // v1.16.9 — carry the recorded site / dose override across the - // tombstone + re-insert: a slot move is a re-binding, not a new - // dose, so the original row's documentation must survive when the - // toggle write itself carries none. - injectionSite: - resolvedInjectionSite ?? - (existing.injectionSite as InjectionSiteValue | null), - doseTaken: existing.doseTaken, - }); - updated = applied.row; - await prisma.medication.update({ - where: { id: existing.medicationId }, - data: { snoozedUntil: null }, - }); - // v1.16.10 — consume on the converged row (the old row's stamp - // was refunded above, so the move nets one consumption). 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 target would double-charge — skip it. - if (!wasTaken || existing.inventoryConsumption !== null) { - await consumeForIntake({ - client: prisma, - userId: user.id, - medicationId: existing.medicationId, - eventId: applied.row.id, - intakeAt: resolvedTakenAt, - }); - } - } - } else if (status === "skipped") { - updated = await prisma.medicationIntakeEvent.update({ - where: { id: intakeId }, - // v1.7.0 sync — bump the reconciliation counter on the skip toggle. - // v1.42.2 — a skip the person records clears the automatic marker. - data: { - takenAt: null, - skipped: true, - autoRecorded: false, - syncVersion: { increment: 1 }, - }, - }); - // v1.16.10 — the row stopped being taken; refund whatever its - // consumption stamp recorded (no-op for a never-consumed row). - await restoreForIntake({ - client: prisma, - userId: user.id, - eventId: intakeId, - }); - } else { - // snoozed: snoozedUntil lives on the Medication row. - const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min - await prisma.medication.update({ - where: { id: existing.medicationId }, - data: { snoozedUntil: until }, - }); - updated = await prisma.medicationIntakeEvent.findUnique({ - where: { id: intakeId }, + const outcome = await updateIntakeStatus( + { + user, + actorId: actor.id, + existing, + ipAddress: getClientIp(request), + originDeviceToken: request.headers.get("x-device-id"), + }, + parsed.data, + ); + if (outcome.kind === "untracked") return refuseUntrackedIntake(); + if (outcome.kind === "refused") { + return apiError(outcome.message, outcome.status, { + errorCode: outcome.errorCode, }); } - - await auditLog("medications.intake.update", { - userId: user.id, - ipAddress: getClientIp(request), - details: { intakeId, status }, - }); - - annotate({ - action: { name: "medications.intake.update" }, - meta: { intakeId, status }, - }); - - // v1.4.34 — bust the medications + compliance + achievement - // caches for this user so the next read reflects the dose change. - invalidateUserMedications(user.id, { evict: true }); - - // v1.4.39 — refresh the persistent compliance rollup row for - // the affected day so the next read after the cache miss returns - // the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort: - // a populator failure annotates ops without blocking the response. - await refreshDoseSlot({ - userId: user.id, - medicationId: existing.medicationId, - scheduledFor: existing.scheduledFor, - tz: userTzForHook, - }); - // v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant, - // possibly a different local day. Recompute the target day too, or the - // source day self-corrects while the destination keeps its stale tuple. - const movedTo = updated?.scheduledFor; - if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { - await refreshDoseSlot({ - userId: user.id, - medicationId: existing.medicationId, - scheduledFor: movedTo, - tz: userTzForHook, - }); - } - - // v1.17.1 (#22) — silent cross-device intake sync. Wake the user's OTHER - // iOS devices so a running Live Activity / Home-Screen widget reconciles - // the dose state changed here. APNs-only, best-effort, coalesced per user: - // the canonical row is already persisted, so a sync-push miss never - // affects the response. The originating device (the one that POSTed) is - // excluded via its `X-Device-Id` (= registered `Device.token`). - queueMedicationIntakeSync({ - userId: user.id, - originDeviceToken: request.headers.get("x-device-id"), - }); - - // v1.18.4 — PWA-only equivalent of the Live Activity end: when a dose is - // resolved (taken / skipped), push a `type:"clear"` to the user's Web Push - // subscriptions so the still-pending dose-due reminder for this slot is - // closed by the service worker (matched on the stable slot tag) and the app - // badge re-reflects the outstanding-dose count. A snooze leaves the dose - // outstanding, so it neither clears the reminder nor changes the badge. - if (status === "taken" || status === "skipped") { - void (async () => { - const badgeCount = await countOutstandingDosesToday( - user.id, - userTzForHook, - ); - await dispatchMedicationIntakeWebClear({ - userId: user.id, - medicationId: existing.medicationId, - scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(), - badgeCount, - }); - })(); - } - - // v1.36.x — "somebody else marked your dose". The helper refuses on self and - // on a snooze, resolves both names itself, and never throws. - // - // Fire and forget, like every neighbour above. The dispatcher awaits each - // channel in the cascade, so an unreachable one would have added its whole - // timeout to the caregiver's request — the person standing next to the - // patient waiting for a dose to register. The helper never throws, and the - // owner learning a moment later is the right trade against the tick being - // slow. The integration test already polls the ledger rather than relying on - // the await, so nothing about the proof weakens. - void notifyDelegatedIntake({ - ownerId: user.id, - actorId: actor.id, - medicationId: existing.medicationId, - status, - }); - - return apiSuccess(updated); + return apiSuccess(outcome.row); }); diff --git a/src/lib/medications/intake-compliance.ts b/src/lib/medications/intake-compliance.ts new file mode 100644 index 000000000..25c74f216 --- /dev/null +++ b/src/lib/medications/intake-compliance.ts @@ -0,0 +1,45 @@ +/** + * Per-day scheduled vs taken for the last N days: + * `GET /api/medications/intake?scope=compliance`. + * + * Cached at a 15-minute TTL because daily compliance buckets are slow-moving + * (intake events trickle in, yesterday's row doesn't move). The cache key + * carries the userTz so a user who changes timezone doesn't read another + * tz's bucketing. + * + * `scheduled` is the SCHEDULE-ANCHORED expected-dose count per day (the + * canonical recurrence engine), NOT the count of logged intake rows. The old + * rollup-backed path set `scheduled = COUNT(*)` of intake rows, so the + * dashboard tile's rate (`taken / scheduled`) was ~100% across every window + * regardless of real adherence — every logged row was both numerator and + * denominator. Anchoring `scheduled` to the schedule makes the rate genuinely + * reflect taken-of-expected and lets the 7/30/90 windows diverge with partial + * adherence. + */ +import { annotate } from "@/lib/logging/context"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { + buildScheduleAnchoredComplianceBuckets, + type ScheduleAnchoredComplianceBucket, +} from "@/lib/analytics/schedule-anchored-compliance"; + +export async function readIntakeCompliance(input: { + userId: string; + days: number; + userTz: string; +}): Promise { + const { userId, days, userTz } = input; + const result = await cached( + caches.medicationsIntake as ServerCache, + `${userId}|compliance|${days}|${userTz}`, + () => buildScheduleAnchoredComplianceBuckets(userId, days, userTz), + annotate, + ); + + annotate({ + action: { name: "medications.intake.compliance" }, + meta: { days, count: result.length }, + }); + + return result; +} diff --git a/src/lib/medications/intake-today.ts b/src/lib/medications/intake-today.ts new file mode 100644 index 000000000..9e6459e2a --- /dev/null +++ b/src/lib/medications/intake-today.ts @@ -0,0 +1,79 @@ +/** + * Today's intake events across the record's medications: + * `GET /api/medications/intake?scope=today`. + * + * The route authenticates (READ over the `medications` section) and parses + * the query; the projection, the read and the status mapping run here. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; +import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; + +export async function readTodayIntakes(input: { + userId: string; + userTz: string; +}) { + const { userId, userTz } = input; + const todayStart = startOfLocalDayInTz(new Date(), userTz); + const todayEnd = new Date(todayStart.getTime() + 86_400_000); + + // Project pending intake rows for every active schedule whose window opens + // today, then idempotently backfill any missing rows. Without it the + // endpoint returned `[]` for daily meds (`schedule.daysOfWeek = null` in the + // DB) until the reminder worker entered the RED phase at the end of the + // dose window — leaving the iOS Dashboard tile + the "Erfassen" sheet empty + // for the whole morning. The shared helper mirrors the dashboard-summary + // call site so the two routes converge on the same row set. + const { projected, backfilled } = await projectTodayIntakesAndRecompute({ + userId, + userTz, + todayStart, + todayEnd, + }); + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + // Exclude tombstoned rows from the today list. + deletedAt: null, + scheduledFor: { gte: todayStart, lt: todayEnd }, + // The today list is what clients offer doses from (#1033); a + // medication with intake tracking off is not on it. + ...TRACKED_INTAKE_EVENT_WHERE, + }, + orderBy: { scheduledFor: "asc" }, + include: { medication: { select: { id: true, snoozedUntil: true } } }, + }); + + annotate({ + action: { name: "medications.intake.today" }, + meta: { + count: events.length, + projected, + backfilled, + }, + }); + + return events.map((e) => ({ + id: e.id, + medicationId: e.medicationId, + scheduledAt: e.scheduledFor.toISOString(), + takenAt: e.takenAt?.toISOString() ?? null, + status: e.skipped + ? "skipped" + : e.takenAt + ? "taken" + : // A never-acted dose the auto-miss cron flipped is a + // terminal MISS, not a perpetual "pending". (Today's freshly + // projected rows are < 24 h old so this rarely fires here, but + // a stale row surfacing in the window reads honestly.) + e.autoMissed + ? "missed" + : e.medication.snoozedUntil && e.medication.snoozedUntil > new Date() + ? "snoozed" + : "pending", + snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null, + })); +} diff --git a/src/lib/medications/update-intake-status/admission.ts b/src/lib/medications/update-intake-status/admission.ts new file mode 100644 index 000000000..9eb840058 --- /dev/null +++ b/src/lib/medications/update-intake-status/admission.ts @@ -0,0 +1,188 @@ +/** + * Whether a status update to `POST /api/medications/intake` may touch the + * event it names: the live event under the record, the record-only rule, the + * delegated refusals, and the injection site it carries. + */ +import type { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; +import { + type InjectionSiteValue, + type intakeStatusUpdateSchema, +} from "@/lib/validations/medication"; +import { isRecordOnly } from "@/lib/medications/intake-tracking"; + +export type IntakeStatusUpdate = z.output; + +/** A refusal the route answers with `apiError(message, status, meta)`. */ +export interface IntakeStatusRefusal { + kind: "refused"; + message: string; + status: number; + errorCode: string; +} + +/** + * The live event a status update may change, with what the site check needs + * of its medication, or null for the route's 404. A tombstoned intake 404s on + * a status toggle; the `deletedAt: null` filter refuses to mutate a + * soft-deleted row. + */ +export async function findIntakeForStatusUpdate(input: { + userId: string; + intakeId: string; +}) { + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { id: input.intakeId, deletedAt: null }, + include: { + medication: { + select: { + deliveryForm: true, + trackInjectionSites: true, + allowedInjectionSites: true, + trackIntake: true, + }, + }, + }, + }); + if (!existing || existing.userId !== input.userId) { + return null; + } + return existing; +} + +export type StatusUpdateEvent = NonNullable< + Awaited> +>; + +/** + * A record-only medication keeps the doses it already has, and an owner may + * still correct one of those (that is an edit of history). What it no longer + * takes is a NEW dose: resolving or snoozing an event that is still open + * would record one, and it would then be counted nowhere (iOS #116, item + * 15). Refused by name, the same code the per-medication route answers with. + */ +export function refusesUntrackedIntake(existing: StatusUpdateEvent): boolean { + const stillOpen = existing.takenAt === null && !existing.skipped; + return stillOpen && isRecordOnly(existing.medication); +} + +/** + * This route is admitted for delegates because marking a dose is the thing a + * person standing next to the patient needs to do. It is an UPDATE, though, + * and two of the transitions it can express are not that. + * + * Snoozing writes `snoozedUntil` on the OWNER's medication row, and the + * reminder cron skips a medication for as long as that stamp is in the + * future. The field is unbounded, the owner is deliberately not notified of + * a delegated snooze (there is nothing useful to say about a thirty-minute + * deferral), and the activity feed renders the whole family as "marked a + * dose". So a delegate could switch the owner's medication reminders off for + * years, and every surface that exists to make delegation visible would + * describe it as marking a dose. In an adherence product that is the worst + * thing on this route. + * + * Flipping an already-resolved event is the other one: taken to skipped and + * back rewrites the owner's compliance history and refunds inventory. The + * shipped promise, on the consent screen and in the release notes, is that + * editing what is already there stays with the owner. Marking a dose that is + * still open is a contribution; changing a decision the owner already + * recorded is an edit. + * + * Both refuse for a delegate and stay open to the owner, who is the only + * person the snooze and the correction were ever for. + */ +export async function refuseDelegatedChange(input: { + userId: string; + actorId: string; + existing: StatusUpdateEvent; + intakeId: string; + status: IntakeStatusUpdate["status"]; + ipAddress: string | null; +}): Promise { + const { userId, existing, intakeId, status } = input; + if (input.actorId !== userId) { + const alreadyResolved = existing.takenAt !== null || existing.skipped; + const refusal = + status === "snoozed" + ? "snooze" + : alreadyResolved + ? "resolved_event" + : null; + if (refusal) { + annotate({ + action: { name: "medications.intake.update.refused" }, + meta: { reason: refusal }, + }); + // Filed under the OWNER. `auditLog` stamps the actor itself from the + // request's acting context, which is what puts this on the trail the + // owner's activity panel reads — a refused attempt to change their + // record is worth as much to them as a successful one. + await auditLog("medications.intake.update.refused", { + userId, + ipAddress: input.ipAddress, + details: { intakeId, status, reason: refusal }, + }); + return { + kind: "refused", + message: + refusal === "snooze" + ? "Snoozing a reminder is not part of shared access" + : "Changing a dose that is already recorded is not part of shared access", + status: 403, + errorCode: "sharing.not_permitted", + }; + } + } + return null; +} + +/** + * Resolve + server-validate the optional injection site for a "taken" + * toggle. A site outside the medication's effective allowed set is a hard + * 422; non-injection / tracking-off / non-taken drops it. + */ +export async function resolveStatusInjectionSite(input: { + userId: string; + existing: StatusUpdateEvent; + injectionSite: IntakeStatusUpdate["injectionSite"]; + status: IntakeStatusUpdate["status"]; +}): Promise< + { kind: "site"; site: InjectionSiteValue | null } | IntakeStatusRefusal +> { + const { existing, injectionSite, status } = input; + let resolvedInjectionSite: InjectionSiteValue | null = null; + if (injectionSite !== undefined) { + const userRow = await prisma.user.findUnique({ + where: { id: input.userId }, + select: { globalExcludedInjectionSites: true }, + }); + const resolution = resolveInjectionSiteForWrite({ + submitted: injectionSite, + taken: status === "taken", + deliveryForm: existing.medication.deliveryForm, + trackInjectionSites: existing.medication.trackInjectionSites, + allowedInjectionSites: existing.medication + .allowedInjectionSites as InjectionSiteValue[], + globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ?? + []) as InjectionSiteValue[], + }); + if (resolution.kind === "disallowed") { + annotate({ + action: { name: "medication.intake.injection_site.disallowed" }, + meta: { medication_id: existing.medicationId, site: resolution.site }, + }); + return { + kind: "refused", + message: "Injection site is not allowed for this medication", + status: 422, + errorCode: "medications.intake.injection_site.disallowed", + }; + } + resolvedInjectionSite = resolution.site; + } + return { kind: "site", site: resolvedInjectionSite }; +} diff --git a/src/lib/medications/update-intake-status/take.ts b/src/lib/medications/update-intake-status/take.ts new file mode 100644 index 000000000..5f7cec874 --- /dev/null +++ b/src/lib/medications/update-intake-status/take.ts @@ -0,0 +1,198 @@ +/** + * A "taken" status update: re-run window-band attribution so an edited / + * off-window take re-binds to the right slot instead of leaving + * `scheduledFor` stale (audit HIGH-4). `forceSlotInstant` pins onto a named + * real slot (422 if it is not one); otherwise band membership picks the slot + * (the take's own time on a miss → ad-hoc). Mirrors the per-event PUT route. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; +import type { InjectionSiteValue } from "@/lib/validations/medication"; +import type { IntakeStatusRefusal, StatusUpdateEvent } from "./admission"; + +interface TakeInput { + userId: string; + intakeId: string; + existing: StatusUpdateEvent; + takenAt: Date | undefined; + forceSlotInstant: Date | undefined; + resolvedInjectionSite: InjectionSiteValue | null; + userTz: string; +} + +async function targetSlotOf( + input: TakeInput, + resolvedTakenAt: Date, +): Promise { + const { userId, existing, intakeId, forceSlotInstant, userTz } = input; + if (forceSlotInstant !== undefined) { + const forced = await resolveForcedSlotForWrite({ + userId, + medicationId: existing.medicationId, + userTz, + slotInstant: forceSlotInstant, + }); + if (forced === null) { + annotate({ + action: { name: "medication.intake.force_slot.invalid" }, + meta: { medication_id: existing.medicationId, intake_id: intakeId }, + }); + return { + kind: "refused", + message: "forceSlotInstant is not a scheduled slot of this medication", + status: 422, + errorCode: "medications.intake.force_slot.invalid", + }; + } + return forced; + } + const attribution = await resolveSlotForWriteByBand({ + userId, + medicationId: existing.medicationId, + userTz, + takenAt: resolvedTakenAt, + }); + return attribution.slotInstant ?? resolvedTakenAt; +} + +async function takeInPlace( + input: TakeInput, + resolvedTakenAt: Date, + wasTaken: boolean, +) { + const { userId, intakeId, existing, resolvedInjectionSite } = input; + const [updated] = await prisma.$transaction([ + prisma.medicationIntakeEvent.update({ + where: { id: intakeId }, + // Bump the reconciliation counter on every + // server-side mutation so the delta feed echoes a monotonic value. + data: { + takenAt: resolvedTakenAt, + skipped: false, + // The person confirmed it; no longer automatic. + autoRecorded: false, + syncVersion: { increment: 1 }, + // Persist the resolved site on the taken branch. + ...(resolvedInjectionSite !== null && { + injectionSite: resolvedInjectionSite, + }), + }, + }), + prisma.medication.update({ + where: { id: existing.medicationId }, + data: { snoozedUntil: null }, + }), + ]); + // The toggle recorded a take; consume inventory units + // on the genuine pending→taken (or skipped→taken) transition. The + // stamp additionally keeps a racing replay exactly-once. + if (!wasTaken) { + await consumeForIntake({ + client: prisma, + userId, + medicationId: existing.medicationId, + eventId: intakeId, + intakeAt: resolvedTakenAt, + }); + } + return updated; +} + +/** + * The take re-attributed to a different slot. Tombstone the source row and + * route the dose through the shared canonical-slot upsert, which converges + * onto any row already at the target slot rather than bare-updating into an + * occupied slot (P2002-safe). + * + * Refund the source row's consumption stamp BEFORE the tombstone, then + * consume on the converged row below: a slot move is a re-binding of one + * real dose, so the stock nets exactly one consumption. + */ +async function moveTake( + input: TakeInput, + resolvedTakenAt: Date, + targetScheduledFor: Date, + wasTaken: boolean, +) { + const { userId, intakeId, existing, resolvedInjectionSite } = input; + await restoreForIntake({ + client: prisma, + userId, + eventId: intakeId, + }); + await prisma.medicationIntakeEvent.update({ + where: { id: intakeId }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId, + medicationId: existing.medicationId, + canonicalSlot: targetScheduledFor, + takenAt: resolvedTakenAt, + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey: null, + createSource: "WEB", + // Carry the recorded site / dose override across the + // tombstone + re-insert: a slot move is a re-binding, not a new + // dose, so the original row's documentation must survive when the + // toggle write itself carries none. + injectionSite: + resolvedInjectionSite ?? + (existing.injectionSite as InjectionSiteValue | null), + doseTaken: existing.doseTaken, + }); + await prisma.medication.update({ + where: { id: existing.medicationId }, + data: { snoozedUntil: null }, + }); + // Consume on the converged row (the old row's stamp + // was refunded above, so the move nets one consumption). 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 target would double-charge — skip it. + if (!wasTaken || existing.inventoryConsumption !== null) { + await consumeForIntake({ + client: prisma, + userId, + medicationId: existing.medicationId, + eventId: applied.row.id, + intakeAt: resolvedTakenAt, + }); + } + return applied.row; +} + +/** Record the take, in place or on the slot it now binds to. */ +export async function applyTakenStatus(input: TakeInput) { + const resolvedTakenAt = input.takenAt ?? new Date(); + + const target = await targetSlotOf(input, resolvedTakenAt); + if (!(target instanceof Date)) return target; + const targetScheduledFor = target; + + const slotMoved = + targetScheduledFor.getTime() !== input.existing.scheduledFor.getTime(); + + // Transition gate for the inventory consume. Only + // a row moving INTO taken may consume; a re-take of an already- + // taken row (e.g. a time correction) leaves its stamp frozen, and a + // pre-v1.16.10 taken row (NULL stamp, stock already moved by the + // legacy hook at take time) must never retro-consume. + const wasTaken = input.existing.takenAt !== null && !input.existing.skipped; + const updated = !slotMoved + ? await takeInPlace(input, resolvedTakenAt, wasTaken) + : await moveTake(input, resolvedTakenAt, targetScheduledFor, wasTaken); + return { kind: "updated" as const, row: updated }; +} diff --git a/src/lib/medications/update-intake-status/update-intake-status.ts b/src/lib/medications/update-intake-status/update-intake-status.ts new file mode 100644 index 000000000..4263ae952 --- /dev/null +++ b/src/lib/medications/update-intake-status/update-intake-status.ts @@ -0,0 +1,250 @@ +/** + * Mark, skip or snooze one intake event: `POST /api/medications/intake`. + * + * The route authenticates (WRITE over the `medications` section), parses the + * body and answers the schema refusal and the 404; the record-only rule, the + * delegated refusals, the injection site, the status write and every tail + * after it run here, in the order they always ran. Everything downstream + * addresses the RECORD: the cross-device intake sync wakes the OWNER's iOS + * devices, the web-clear closes the OWNER's pending dose reminder and + * rewrites the OWNER's badge, and the compliance rollup recomputes the + * OWNER's day. The owner is told who marked the dose at the end. + */ +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 { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; +import { restoreForIntake } from "@/lib/medications/inventory/consumption"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; +import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; +import { + refuseDelegatedChange, + refusesUntrackedIntake, + resolveStatusInjectionSite, + type IntakeStatusRefusal, + type IntakeStatusUpdate, + type StatusUpdateEvent, +} from "./admission"; +import { applyTakenStatus } from "./take"; + +interface StatusContext { + user: Pick; + actorId: string; + existing: StatusUpdateEvent; + ipAddress: string | null; + originDeviceToken: string | null; +} + +type UpdatedRow = Extract< + Awaited>, + { kind: "updated" } +>["row"]; + +export type IntakeStatusOutcome = + | IntakeStatusRefusal + | { kind: "untracked" } + | { kind: "updated"; row: UpdatedRow | null }; + +/** The skip and the snooze; the take has its own module. */ +async function applySkipOrSnooze(ctx: StatusContext, data: IntakeStatusUpdate) { + const { user, existing } = ctx; + const { intakeId, status, snoozedUntil } = data; + if (status === "skipped") { + const updated = await prisma.medicationIntakeEvent.update({ + where: { id: intakeId }, + // Bump the reconciliation counter on the skip toggle. + // A skip the person records clears the automatic marker. + data: { + takenAt: null, + skipped: true, + autoRecorded: false, + syncVersion: { increment: 1 }, + }, + }); + // The row stopped being taken; refund whatever its + // consumption stamp recorded (no-op for a never-consumed row). + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: intakeId, + }); + return updated; + } + // snoozed: snoozedUntil lives on the Medication row. + const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min + await prisma.medication.update({ + where: { id: existing.medicationId }, + data: { snoozedUntil: until }, + }); + return prisma.medicationIntakeEvent.findUnique({ + where: { id: intakeId }, + }); +} + +/** + * The audit row, the event, the caches and the rollup rows of the day the + * event sat on and, after a slot move, of the day it moved to. + */ +async function recordAfterUpdate( + ctx: StatusContext, + data: IntakeStatusUpdate, + updated: UpdatedRow | null, + userTzForHook: string, +): Promise { + const { user, existing } = ctx; + const { intakeId, status } = data; + await auditLog("medications.intake.update", { + userId: user.id, + ipAddress: ctx.ipAddress, + details: { intakeId, status }, + }); + + annotate({ + action: { name: "medications.intake.update" }, + meta: { intakeId, status }, + }); + + // Bust the medications + compliance + achievement + // caches for this user so the next read reflects the dose change. + invalidateUserMedications(user.id, { evict: true }); + + // Refresh the persistent compliance rollup row for + // the affected day so the next read after the cache miss returns + // the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort: + // a populator failure annotates ops without blocking the response. + await refreshDoseSlot({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: existing.scheduledFor, + tz: userTzForHook, + }); + // A slot move re-binds the dose onto a DIFFERENT instant, + // possibly a different local day. Recompute the target day too, or the + // source day self-corrects while the destination keeps its stale tuple. + const movedTo = updated?.scheduledFor; + if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { + await refreshDoseSlot({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: movedTo, + tz: userTzForHook, + }); + } + return movedTo; +} + +/** The tails that never affect the response. */ +function startTails( + ctx: StatusContext, + status: IntakeStatusUpdate["status"], + movedTo: Date | undefined, + userTzForHook: string, +): void { + const { user, existing } = ctx; + // (#22) — silent cross-device intake sync. Wake the user's OTHER + // iOS devices so a running Live Activity / Home-Screen widget reconciles + // the dose state changed here. APNs-only, best-effort, coalesced per user: + // the canonical row is already persisted, so a sync-push miss never + // affects the response. The originating device (the one that POSTed) is + // excluded via its `X-Device-Id` (= registered `Device.token`). + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: ctx.originDeviceToken, + }); + + // PWA-only equivalent of the Live Activity end: when a dose is + // resolved (taken / skipped), push a `type:"clear"` to the user's Web Push + // subscriptions so the still-pending dose-due reminder for this slot is + // closed by the service worker (matched on the stable slot tag) and the app + // badge re-reflects the outstanding-dose count. A snooze leaves the dose + // outstanding, so it neither clears the reminder nor changes the badge. + if (status === "taken" || status === "skipped") { + void (async () => { + const badgeCount = await countOutstandingDosesToday( + user.id, + userTzForHook, + ); + await dispatchMedicationIntakeWebClear({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(), + badgeCount, + }); + })(); + } + + // "Somebody else marked your dose". The helper refuses on self and + // on a snooze, resolves both names itself, and never throws. + // + // Fire and forget, like every neighbour above. The dispatcher awaits each + // channel in the cascade, so an unreachable one would have added its whole + // timeout to the caregiver's request — the person standing next to the + // patient waiting for a dose to register. The helper never throws, and the + // owner learning a moment later is the right trade against the tick being + // slow. The integration test already polls the ledger rather than relying on + // the await, so nothing about the proof weakens. + void notifyDelegatedIntake({ + ownerId: user.id, + actorId: ctx.actorId, + medicationId: existing.medicationId, + status, + }); +} + +/** Apply the status update to the event the route found. */ +export async function updateIntakeStatus( + ctx: StatusContext, + data: IntakeStatusUpdate, +): Promise { + const { user, existing } = ctx; + const { intakeId, status } = data; + + if (refusesUntrackedIntake(existing)) return { kind: "untracked" }; + + const refused = await refuseDelegatedChange({ + userId: user.id, + actorId: ctx.actorId, + existing, + intakeId, + status, + ipAddress: ctx.ipAddress, + }); + if (refused) return refused; + + const site = await resolveStatusInjectionSite({ + userId: user.id, + existing, + injectionSite: data.injectionSite, + status, + }); + if (site.kind === "refused") return site; + + const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE; + + let updated: UpdatedRow | null; + if (status === "taken") { + const taken = await applyTakenStatus({ + userId: user.id, + intakeId, + existing, + takenAt: data.takenAt, + forceSlotInstant: data.forceSlotInstant, + resolvedInjectionSite: site.site, + userTz: userTzForHook, + }); + if (taken.kind === "refused") return taken; + updated = taken.row; + } else { + updated = await applySkipOrSnooze(ctx, data); + } + + const movedTo = await recordAfterUpdate(ctx, data, updated, userTzForHook); + startTails(ctx, status, movedTo, userTzForHook); + + return { kind: "updated", row: updated }; +} diff --git a/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts b/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts index 4dd0a94c1..4bac6a6ea 100644 --- a/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts +++ b/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts @@ -257,6 +257,10 @@ const HANDLER_BODY_MODULES: Record = { "app/api/medications/[id]/intake/route.ts": [ "lib/medications/record-intake/record-intake.ts", ], + "app/api/medications/intake/route.ts": [ + "lib/medications/update-intake-status/admission.ts", + "lib/medications/update-intake-status/update-intake-status.ts", + ], }; function handlerAuditActions(file: string): string[] {