refactor(medications): move the today list, the compliance buckets and the status update out of their route

GET and POST /api/medications/intake keep authentication, the query and body parsing with their refusals, the 404 and the HTTP mapping. The today list moves to lib/medications/intake-today.ts and the cached compliance buckets to intake-compliance.ts. The status update, one 432-line callback, moves to lib/medications/update-intake-status/ in the order it always ran: the event lookup, the record-only rule, the delegated refusals and the injection site (admission.ts), the take in place or onto another slot (take.ts), then the skip or snooze, the audit row, the rollups and the tails (update-intake-status.ts). The route falls from 634 to 149 lines; no function in the new modules is over 70.

The parity walk recorded before the move (medication-intake-status.parity) is unchanged. The sharing and activity-verb guards read the status-update modules beside the route, and the dense-read guard keeps the moved code in scope.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 14:37:47 +02:00
1 parent baa1ba018d
commit 2fcc2bdab2
9 files changed
+815 -529

No files matched your search

@@ -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. */
@@ -442,6 +442,10 @@ const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
"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",
+44 -529
View File
@@ -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<ScheduleAnchoredComplianceBucket[]>,
`${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);
});
+45
View File
@@ -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<ScheduleAnchoredComplianceBucket[]> {
const { userId, days, userTz } = input;
const result = await cached(
caches.medicationsIntake as ServerCache<ScheduleAnchoredComplianceBucket[]>,
`${userId}|compliance|${days}|${userTz}`,
() => buildScheduleAnchoredComplianceBuckets(userId, days, userTz),
annotate,
);
annotate({
action: { name: "medications.intake.compliance" },
meta: { days, count: result.length },
});
return result;
}
+79
View File
@@ -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,
}));
}
@@ -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<typeof intakeStatusUpdateSchema>;
/** 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<ReturnType<typeof findIntakeForStatusUpdate>>
>;
/**
* 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<IntakeStatusRefusal | null> {
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 };
}
@@ -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<Date | IntakeStatusRefusal> {
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 };
}
@@ -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<SessionUser, "id" | "timezone">;
actorId: string;
existing: StatusUpdateEvent;
ipAddress: string | null;
originDeviceToken: string | null;
}
type UpdatedRow = Extract<
Awaited<ReturnType<typeof applyTakenStatus>>,
{ 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<Date | undefined> {
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<IntakeStatusOutcome> {
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 };
}
@@ -257,6 +257,10 @@ const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
"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[] {