Merge branch 'feat/v146-server-meds2' into feat/v146-server

# Conflicts:
#	src/__tests__/external-id-stability-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 14:52:03 +02:00
commit 40eb1255b7
148 files changed
+96447 -3191

No files matched your search

+52 -619
View File
@@ -1,4 +1,3 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -18,32 +17,13 @@ import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { withIdempotency } from "@/lib/idempotency";
import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import {
assertMedicationOwnership,
assertMedicationTakesIntake,
} from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import {
applyCanonicalSlotWrite,
findPinConflict,
mayConvergeOntoSuppliedSlot,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { recordIntake } from "@/lib/medications/record-intake/record-intake";
import { listIntakeEvents } from "@/lib/medications/list-intake-events";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -53,25 +33,25 @@ export const POST = apiHandler(
);
async function postIntake(request: NextRequest, { params }: RouteParams) {
// v1.36.x — a delegated write, the per-medication form of the same verb.
// A delegated write, the per-medication form of the same verb.
// `user` is the record the dose belongs to; `actor` is whoever pressed the
// button, and is used for nothing but the owner's notification below.
// button, and is used for nothing but the owner's notification.
const { user, actor, authMethod } = await requireRecordAuth(
"write",
"medications",
);
// v1.36.1 — the sibling route `POST /api/medications/intake`
// refuses a delegate changing a dose the owner already recorded. This route
// reaches the same rows through the slot upsert, so without the same rule it
// is simply the way round the other one: an explicit skip posted onto a
// taken slot flips the outcome and refunds the inventory the take consumed.
// The flag rides into `applyCanonicalSlotWrite`, which is where the existing
// row is already loaded, so the check adds no query and cannot race.
// The sibling route `POST /api/medications/intake` refuses a delegate
// changing a dose the owner already recorded. This route reaches the same
// rows through the slot upsert, so without the same rule it is simply the
// way round the other one: an explicit skip posted onto a taken slot flips
// the outcome and refunds the inventory the take consumed. The flag rides
// into `applyCanonicalSlotWrite`, which is where the existing row is
// already loaded, so the check adds no query and cannot race.
const isDelegated = actor.id !== user.id;
/**
* The refusal itself, shared by both upsert call sites so the two cannot
* The refusal itself, answered for both upsert call sites so the two cannot
* drift apart. Files under the OWNER — `auditLog` stamps the actor from the
* request's acting context — so a refused attempt reaches the activity panel
* the same way the sibling route's does, under the same action name.
@@ -92,7 +72,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
{ errorCode: "sharing.not_permitted" },
);
}
// v1.32.8 (iOS #64) — write provenance derived from the transport, never
// Write provenance derived from the transport (iOS #64), never
// client-asserted: a Bearer/native call stores `API`, a cookie/browser call
// stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER
// routes (the Telegram worker mints `REMINDER`, the bulk/import paths mint
@@ -103,8 +83,8 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
const { id } = await params;
// v1.4.25 — privacy gate hoisted to the shared helper. This is a
// dose write, so the guard also refuses a record-only medication with
// Privacy gate through the shared helper. This is a dose write, so the
// guard also refuses a record-only medication with
// `medication.intake.notTracked` (iOS #116, item 15): its history stays,
// but a new dose would be stored and then counted nowhere.
const guard = await assertMedicationTakesIntake(id, user.id);
@@ -120,8 +100,8 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
medicationId: id,
});
if (!parsed.success) {
// v1.4.43 — per-med intake POST hot path; multi-issue 422 +
// audit breadcrumb keyed `medications.intake.create.validation-failed`.
// Per-med intake POST hot path; multi-issue 422 + audit breadcrumb keyed
// `medications.intake.create.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
const shape = unstableExternalIdShape(body, "idempotencyKey");
annotate({
@@ -134,12 +114,12 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
: {}),
},
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// intake payload carries `idempotencyKey` (opaque caller string).
// Strip `message` from the audit-ledger row; the intake payload carries
// `idempotencyKey` (opaque caller string).
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
// Through `auditLog()`, the only writer that stamps the actor.
void auditLog("medications.intake.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues, medicationId: id },
@@ -149,515 +129,28 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
return returnAllZodIssues(parsed.error, 422);
}
const {
scheduledFor,
takenAt,
skipped,
idempotencyKey,
injectionSite,
forceSlotInstant,
doseTaken,
} = parsed.data;
// v1.16.4 — the dose override documents a consumed dose, so it only
// applies to a taken (non-skipped) write; on a skip it is silently
// dropped (the slot consumed nothing).
const resolvedDoseTaken = !skipped && doseTaken ? doseTaken : null;
// v1.8.5 — resolve + server-validate the optional injection site. Load
// the medication's delivery form + tracking opt-in + per-medication
// allowed sites, plus the user's global exclusion deny-list. A site
// outside the effective allowed set is a hard 422; a site on a
// non-injection / tracking-off med (or a skip) is silently dropped.
let resolvedInjectionSite: InjectionSiteKey | null = null;
if (injectionSite !== undefined) {
const [med, userRow] = await Promise.all([
prisma.medication.findUnique({
where: { id },
select: {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
}),
]);
const resolution = resolveInjectionSiteForWrite({
submitted: injectionSite,
taken: !skipped,
deliveryForm: med?.deliveryForm ?? "ORAL",
trackInjectionSites: med?.trackInjectionSites ?? false,
allowedInjectionSites: (med?.allowedInjectionSites ??
[]) as InjectionSiteKey[],
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteKey[],
});
if (resolution.kind === "disallowed") {
annotate({
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: id, site: resolution.site },
});
return apiError(
"Injection site is not allowed for this medication",
422,
{
errorCode: "medications.intake.injection_site.disallowed",
},
);
}
resolvedInjectionSite = resolution.site;
}
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
// The per-med route only ever carries an explicit user gesture:
// `resolvedTakenAt` is `now()` for any non-skip POST (there is no
// "mark pending" write on this route), so a non-skip is an explicit
// taken and a `skipped:true` body is an explicit skip. Neither is a
// pending projection echo, so the no-downgrade guard never trips here —
// it is the bulk/sync route that replays pending echoes. The flags are
// threaded through so the shared upsert applies last-write-wins.
const isExplicitTaken = !skipped;
const isExplicitSkip = skipped === true;
// v1.15.18 — window-band slot attribution (replaces the wide ±6h
// `snapToleranceMs` nearest-snap). The take is bound to a slot by
// membership in that slot's configurable dose window — the SAME bands the
// read ledger + the compliance % consume, so the three surfaces can never
// disagree. A take that lands in no window is ad-hoc (`canonicalSlot` null)
// and records as a standalone "taken now" row; PRN meds always insert
// standalone. Resolved BEFORE the idempotency/dedup window so a scheduled
// dose routes through the slot upsert (which is itself the dedup).
//
// - SKIP: a skip is logged against a slot deliberately (it carries no
// `takenAt` to attribute by), so it keeps the canonical `scheduledFor`
// snap that binds it to the slot's pending REMINDER row.
// - TAKEN: attribute by `takenAt` band membership. The optional
// `forceSlotInstant` pins an off-window take onto a chosen real slot
// ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422.
let canonicalSlot: Date | null = null;
// v1.15.20 — binding provenance for the written row: USER_PIN on the
// forced "diesem Slot zuordnen" path, AUTO when band attribution decided.
// Skips carry no binding decision (undefined → column untouched/default).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (skipped) {
if (scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band membership
// on the skip moment, like a take does. The defaulted-now snap only
// reached the tight reminder-grace window, so a skip tapped past it
// landed as an orphan ad-hoc row while the slot's pending REMINDER
// row stayed open to auto-miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
} else {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: true,
isTakenWrite: false,
});
}
} else if (forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
const outcome = await recordIntake(
{
user,
actorId: actor.id,
medicationId: id,
userTz: user.timezone,
slotInstant: forceSlotInstant,
});
if (canonicalSlot === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: id },
});
return apiError(
"forceSlotInstant is not a scheduled slot of this medication",
422,
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
// v1.16.0 — refuse to pin onto a slot another recorded action already
// serves: the explicit-write last-write-wins rule would silently
// overwrite that dose record. The ledger UI only offers the pin for
// unserved slots, so this only fires for stale clients / raw API calls.
if (
await findPinConflict({
userId: user.id,
medicationId: id,
canonicalSlot,
incomingTakenAt: resolvedTakenAt ?? null,
})
) {
annotate({
action: { name: "medication.intake.force_slot.occupied" },
meta: { medication_id: id },
});
return apiError(
"forceSlotInstant already carries a recorded dose action",
422,
{ errorCode: "medications.intake.force_slot.occupied" },
);
}
attributionSource = "USER_PIN";
} else {
// A non-skip write on this route always carries a `takenAt` (defaulted to
// now), so `resolvedTakenAt` is non-null here; the fallback only guards the
// type.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: resolvedTakenAt ?? incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
// A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
// stale USER_PIN when this write converges onto a previously-pinned row.
attributionSource = "AUTO";
// The client can NAME the dose it is recording, and when it does, that
// answer outranks the band. The Verlauf ledger's "Genommen" posts the
// displayed slot's own anchor, and the medication card posts its
// display-due slot; both mean "record THIS dose". Band attribution answers
// a different question — which slot the take's clock time falls into — so
// backfilling a slot from an earlier day matched no band, fell through to
// the standalone insert, and left the named slot unserved: the entry
// reverted to missed on the next read and the dose the user recorded was
// filed on the wrong day.
//
// Only a REAL slot of this medication is honoured (the same band-anchor
// validation the "diesem Slot zuordnen" pin runs), so an arbitrary client
// instant still records ad-hoc rather than parking a live row on a
// fabricated anchor. And only a slot that does not sit ahead of the take,
// by the same forward guard the convergence probe below uses — a
// late-morning dose must never consume the evening slot.
if (
scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped,
takenAt: resolvedTakenAt,
suppliedSlot: incomingScheduledFor,
})
) {
const namedSlot = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
slotInstant: incomingScheduledFor,
});
// Only when the naming actually decides something the clock did not.
// A dose taken inside its own window resolves to the same anchor either
// way and stays an AUTO binding, so an ordinary on-time take is
// unchanged. When the two disagree the binding rests on the user's
// choice, which is what USER_PIN records — and the read ledger binds a
// USER_PIN take by its stored anchor rather than by the clock, so a
// backfilled dose reads under the slot it was recorded for instead of
// falling back to ad-hoc.
if (
namedSlot !== null &&
namedSlot.getTime() !== canonicalSlot?.getTime()
) {
canonicalSlot = namedSlot;
attributionSource = "USER_PIN";
}
}
}
// Idempotency check (explicit key or server-side dedup window)
if (idempotencyKey) {
const existing = await prisma.medicationIntakeEvent.findFirst({
where: {
idempotencyKey,
userId: user.id,
medicationId: id,
},
});
if (existing) {
return apiSuccess(existing);
}
} else if (!canonicalSlot) {
// Unscheduled / PRN only — the slot upsert handles dedup for
// scheduled doses by collapsing onto the canonical slot row.
// Server-side dedup: prevent double-logging within 60 seconds.
const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
medicationId: id,
skipped,
createdAt: { gte: new Date(Date.now() - 60_000) },
},
orderBy: { createdAt: "desc" },
});
if (recentDuplicate) {
return apiSuccess(recentDuplicate);
}
}
let event;
// v1.8.2 — whether this write moved the slot pending→taken.
// Only that transition decrements pen inventory (M2). For the
// unscheduled/PRN branch a non-skip write always records a fresh dose,
// so it consumes when not skipped.
let consumedTransition = !skipped;
if (canonicalSlot) {
// Scheduled dose — converge onto the one canonical slot row regardless
// of `source` (the pending REMINDER row, or any prior row for this
// slot) through the shared upsert: H1 deterministic selection, C2
// no-downgrade guard, and a C1 race-safe create that re-finds + updates
// on a P2002 collision rather than 500-ing or duplicating.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: id,
canonicalSlot,
takenAt: resolvedTakenAt,
skipped,
isExplicitTaken,
isExplicitSkip,
idempotencyKey: idempotencyKey ?? null,
createSource: intakeSource,
refuseOutcomeChange: isDelegated,
// v1.8.5 — resolved + validated site (null unless a tracking-on
// injection taken write supplied an allowed site).
injectionSite: resolvedInjectionSite,
attributionSource,
// v1.16.4 — per-intake dose override (null unless a taken write
// carried one).
doseTaken: resolvedDoseTaken,
});
if (applied.outcomeChangeRefused) return refuseOutcomeChange();
event = applied.row;
consumedTransition = applied.consumedTransition;
// Reset the snooze when a dose is actually recorded (not on a
// no-downgrade no-op, which left the prior taken row untouched).
if (!skipped && !applied.noDowngradeNoOp) {
await prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
});
}
} else {
// Unscheduled / PRN / off-slot. When the client named an explicit
// `scheduledFor`, converge source-agnostically onto any live row that
// already sits on that instant (e.g. the pending REMINDER row the
// worker minted on a slot the band attribution did not claim) before
// inserting. Without the probe the insert lands a second live row for
// the same slot that differs only by `source` — the partial unique
// index carries `source` and cannot catch it — inflating the
// compliance rollup's scheduled count. A defaulted anchor (takenAt /
// now) never names a slot, so the probe is skipped on that hot path.
//
// Dose-safety guard: a TAKEN write must not converge onto a slot whose
// anchor is in the future relative to the take. The card advances its
// display-due to the evening slot once the morning slot's catch-up
// window lapses, so a late-morning "Genommen" posts the evening slot as
// `scheduledFor`; band attribution already rejected the take, and the
// probe must not re-bind it forward onto the evening pending row (a
// late-morning dose silently consuming the 21:00 slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped,
takenAt: resolvedTakenAt,
suppliedSlot: incomingScheduledFor,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
medicationId: id,
scheduledFor: incomingScheduledFor,
deletedAt: null,
},
select: { id: true },
})
: null;
if (existingSlotRow) {
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: id,
canonicalSlot: incomingScheduledFor,
takenAt: resolvedTakenAt,
skipped,
isExplicitTaken,
isExplicitSkip,
idempotencyKey: idempotencyKey ?? null,
createSource: intakeSource,
refuseOutcomeChange: isDelegated,
injectionSite: resolvedInjectionSite,
attributionSource,
doseTaken: resolvedDoseTaken,
});
if (applied.outcomeChangeRefused) return refuseOutcomeChange();
event = applied.row;
consumedTransition = applied.consumedTransition;
if (!skipped && !applied.noDowngradeNoOp) {
await prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
});
}
} else {
// Genuinely standalone. Anchor a taken write on the intake instant —
// the documented ad-hoc contract (`scheduledFor = takenAt`) — so an
// unresolvable client anchor can never park a live row exactly on a
// slot instant a pending REMINDER row is minted for later. A skip
// without a slot keeps the incoming instant (it has no takenAt).
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: resolvedTakenAt ?? incomingScheduledFor,
takenAt: resolvedTakenAt,
skipped,
source: intakeSource,
idempotencyKey: idempotencyKey ?? null,
// v1.8.5 — site only on a resolved taken-injection write.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
// v1.16.4 — dose override only on a taken write carrying one.
...(resolvedDoseTaken !== null && {
doseTaken: resolvedDoseTaken,
}),
},
}),
// Reset snooze when medication is taken
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
}
}
// v1.16.10 — inventory consumption / restore. A taken write consumes
// `unitsPerDose` units (the stamp on the event row makes replays and
// re-posts exactly-once; `consumedTransition` skips the read for the
// no-op echo cases the slot upsert already classified). An explicit
// skip can downgrade a previously-taken row (last-write-wins), so it
// refunds whatever that row's stamp recorded. Both hooks are
// best-effort and never block the intake write.
if (!skipped && consumedTransition) {
await consumeForIntake({
client: prisma,
userId: user.id,
medicationId: id,
eventId: event.id,
intakeAt: event.takenAt ?? event.scheduledFor,
});
} else if (skipped) {
await restoreForIntake({
client: prisma,
userId: user.id,
eventId: event.id,
});
}
await auditLog("medication.intake", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
eventId: event.id,
skipped,
intakeSource,
isDelegated,
ipAddress: getClientIp(request),
originDeviceToken: request.headers.get("x-device-id"),
},
});
annotate({
action: {
name: "medication.intake",
entity_type: "intake_event",
entity_id: event.id,
},
meta: {
medication_id: id,
skipped,
},
});
// v1.4.34 — bust per-user medications + compliance + achievement
// caches so the next read reflects the dose event.
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 — refresh the persistent compliance rollup for the
// affected day. The hook is best-effort; failures annotate but never
// block the user's POST response.
await refreshDoseSlot({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
tz: user.timezone,
});
// v1.5.0 — one-shot lifecycle reconciliation. A `oneShot` medication
// has at most one live intake; the helper re-reads the most recent
// non-skipped intake and flips `active` to match. Idempotent on
// non-one-shot medications (the underlying updateMany is gated by
// `oneShot:true`). The flip runs AFTER the intake row is committed
// so a flaky write never deactivates a medication that didn't
// actually receive its dose.
const reconcileAction = await reconcileOneShotState(prisma, id, user.id);
if (reconcileAction !== "noop") {
invalidateUserMedications(user.id, { evict: true });
parsed.data,
);
switch (outcome.kind) {
case "refused":
return apiError(outcome.message, 422, { errorCode: outcome.errorCode });
case "existing":
return apiSuccess(outcome.row);
case "outcome_change_refused":
return refuseOutcomeChange();
case "recorded":
return apiSuccess(outcome.event, 201);
}
// (#22) — silent cross-device intake sync: wake the user's OTHER iOS
// devices so a running Live Activity / widget reconciles this dose.
// Coalesced per user, best-effort — never affects the response.
queueMedicationIntakeSync({
userId: user.id,
originDeviceToken: request.headers.get("x-device-id"),
});
// PWA counterpart of the sync wake above: a dose resolved here (taken or
// skipped) closes the still-pending dose-due Web Push reminder for the
// slot and refreshes the app badge. The other intake routes have carried
// this since v1.18.4; this route is the replay target of the iOS offline
// queue, so a drained dose must clear the web reminder the same way.
// Best-effort, fire-and-forget — the canonical row is already persisted.
void (async () => {
const badgeCount = await countOutstandingDosesToday(user.id, user.timezone);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor.toISOString(),
badgeCount,
});
})();
// v1.36.x — "somebody else marked your dose". This route has no snooze arm,
// so the state is whichever of the two markings the payload carried. The
// helper refuses on self, so a person marking their own dose is unaffected.
// Fire and forget, like every neighbour above. The dispatcher awaits each
// channel in the cascade, so an unreachable one would have added its whole
// timeout to the caregiver's request — the person standing next to the
// patient waiting for a dose to register. The helper never throws, and the
// owner learning a moment later is the right trade against the tick being
// slow. The integration test already polls the ledger rather than relying on
// the await, so nothing about the proof weakens.
void notifyDelegatedIntake({
ownerId: user.id,
actorId: actor.id,
medicationId: id,
status: skipped ? "skipped" : "taken",
});
return apiSuccess(event, 201);
}
export const GET = apiHandler(
@@ -665,28 +158,28 @@ export const GET = apiHandler(
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
// v1.4.25 — privacy gate hoisted to the shared helper.
// Privacy gate through the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listIntakeEventsSchema.safeParse(searchParams);
if (!parsed.success) {
// v1.4.43 — multi-issue 422 + audit breadcrumb keyed
// Multi-issue 422 + audit breadcrumb keyed
// `medications.intake.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row.
// Strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a delegate's malformed query would read as the owner's own.
// Through `auditLog()` rather than a bare `prisma.auditLog.create`,
// because that helper is the only thing that stamps `actorUserId`.
// Filed under the resolved record either way; without the stamp a
// delegate's malformed query would read as the owner's own.
void auditLog("medications.intake.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues, medicationId: id },
@@ -696,72 +189,12 @@ export const GET = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
const { limit, offset, sortBy, sortDir, status } = parsed.data;
// v1.4.37 — translate the optional `status` filter into a Prisma
// `where` fragment. Default `status:"all"` keeps the contract
// byte-stable for the iOS Swift client and the dashboard tiles that
// were on the wire before this knob existed. The detail-page
// IntakeHistoryListV2 component opts into `status:"completed"` so
// ambiguous "missed / never confirmed" rows
// (`takenAt IS NULL AND skipped = false`) stay out of the user-facing
// table — they were the source of the v1.4.36 regression where rows
// with no takenAt rendered an "Eingenommen" chip.
const statusFilter =
status === "taken"
? { takenAt: { not: null }, skipped: false }
: status === "skipped"
? { skipped: true }
: status === "completed"
? {
OR: [
{ takenAt: { not: null }, skipped: false },
{ skipped: true },
],
}
: {};
// v1.7.0 sync — exclude tombstoned rows from the per-medication
// intake history list + its count.
const where = {
medicationId: id,
userId: user.id,
deletedAt: null,
...statusFilter,
};
// v1.7.0 — pin NULLS LAST on the `takenAt` sort. Skipped rows
// carry `takenAt: null`; under a bare `desc` collation Postgres
// emits NULLS FIRST, floating skipped/planned rows to the top of
// the history view. Pinning them last keeps the descending order
// reading today → yesterday → … with real timestamps first. Other
// sort columns are non-null so they keep the simple shape.
// The trailing `{ id: sortDir }` is the unique tiebreaker. Scheduled
// slots share an instant by construction — a twice-daily medication puts
// every morning dose on the same `scheduledFor` — so offset paging over
// the primary key alone can repeat a row on one page and drop another.
const orderBy =
sortBy === "takenAt"
? [
{ takenAt: { sort: sortDir, nulls: "last" as const } },
{ id: sortDir },
]
: [{ [sortBy]: sortDir }, { id: sortDir }];
const [events, total] = await Promise.all([
prisma.medicationIntakeEvent.findMany({
where,
orderBy,
take: limit,
skip: offset,
return apiSuccess(
await listIntakeEvents({
userId: user.id,
medicationId: id,
query: parsed.data,
}),
prisma.medicationIntakeEvent.count({ where }),
]);
annotate({
action: { name: "medication.intake.list" },
meta: { medication_id: id, total, status },
});
return apiSuccess({ events, meta: { total, limit, offset } });
);
},
);
File diff suppressed because it is too large. Load diff
+17 -830
View File
@@ -45,7 +45,6 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
@@ -56,52 +55,13 @@ import {
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
BACKFILL_SYNC_TRIGGER,
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
MEDICATION_DOSE_ARRIVAL_TYPE,
recordHealthKitTypeArrivals,
} from "@/lib/integrations/healthkit-type-sync";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
dayKeyForScheduledFor,
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
import {
applyCanonicalSlotWrite,
findPinConflict,
mayConvergeOntoSuppliedSlot,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
slotHeldByAnotherDose,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { HEALTHKIT_REQUEST_SYNC_TRIGGERS } from "@/lib/integrations/healthkit-type-sync";
import {
boundedTakenAtSchema,
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
INTAKE_NOT_TRACKED_REASON,
isRecordOnly,
} from "@/lib/medications/intake-tracking";
import { isoDateTime } from "@/lib/validations/datetime";
import { ingestBulkIntake } from "@/lib/medications/intake-bulk/ingest";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -121,7 +81,7 @@ const bulkEntrySchema = z
// equality forever, so it is an identity and carries the same
// stability floor. Checked per entry for the same reason as
// `externalId` above.
// @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts
// @external-id-checked-per-entry: src/lib/medications/intake-bulk/entry.ts
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
// medication's effective allowed set; a disallowed site marks the
@@ -157,7 +117,7 @@ const bulkEntrySchema = z
// Stability is enforced per entry in the loop below, not at the
// field: a schema-level refusal would fail the whole batch on one
// bad row.
// @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts
// @external-id-checked-per-entry: src/lib/medications/intake-bulk/entry.ts
externalId: z.string().trim().min(1).max(128).optional(),
})
.refine(
@@ -181,19 +141,6 @@ const bulkPayloadSchema = z.object({
syncTrigger: z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS).optional(),
});
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
// snaps onto an existing scheduled-slot row (e.g. the pending REMINDER
// row) updates it in place rather than inserting. The iOS sync engine
// treats `updated` the same as `inserted` for cursor advancement — both
// mean "the server accepted this entry and produced a row id".
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
id?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
@@ -203,8 +150,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
const auth = await requireAuth(undefined, { writeBucket: "own" });
const { user } = auth;
// A narrow, single-purpose credential (a bridge, an ingest token) is no
// Apple Health sync, so it never writes the dose arrival record below. The
// bare `requireAuth()` refuses such a credential today; the check keeps the
// Apple Health sync, so it never writes the dose arrival record. The bare
// `requireAuth()` refuses such a credential today; the check keeps the
// record honest should this route ever declare a scope.
const scoped = isScopedCredential(auth);
@@ -238,7 +185,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
// v1.4.43 — bulk intake ingest; preserve the existing
// Bulk intake ingest; preserve the existing
// `medications.intake.bulk.invalid` errorCode meta so the iOS
// Sync engine's retry classifier still branches on it. Audit
// breadcrumb keyed `medications.intake.bulk.validation-failed`.
@@ -247,7 +194,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "medications.intake.bulk.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,
});
@@ -269,776 +216,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
const { entries, syncTrigger } = parsed.data;
// Up-front ownership check — the user can only POST intake events
// against medications they own. One read covers every medicationId
// in the batch.
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
const ownedMedications = await prisma.medication.findMany({
where: { id: { in: medicationIds }, userId: user.id },
// v1.8.5 — pull the injection-tracking fields so a per-entry
// `injectionSite` can be resolved + validated without an extra
// per-entry read. v1.28 — `externalSource` rides along so the
// Apple-entry mirror gate below needs no extra read either.
select: {
id: true,
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
externalSource: true,
trackIntake: true,
},
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
// v1.28 — source-exclusive Apple-mirrored medications: an APPLE_HEALTH
// dose event may only attach to a medication mirrored from Apple Health
// (`Medication.externalSource === "APPLE_HEALTH"`). Targeting a native
// medication is a client contract violation (the iOS sync maps HK dose
// events onto the meds it mirrored itself), not a per-entry data
// problem, so the whole batch 422s with a stable errorCode the client
// can branch on. Unowned medicationIds keep the per-entry
// `medication_not_found` skip below.
const appleMismatch = entries.some(
(e) =>
e.source === "APPLE_HEALTH" &&
ownedSet.has(e.medicationId) &&
medById.get(e.medicationId)?.externalSource !== "APPLE_HEALTH",
);
if (appleMismatch) {
return apiError(
"An APPLE_HEALTH entry may only target a medication mirrored from Apple Health",
422,
{ errorCode: "medications.intake.bulk.apple_health_not_mirrored" },
);
}
// v1.28 — idempotent re-sync dedup: one read collects every live row
// already carrying one of the batch's external dose-event UUIDs; the
// per-entry loop reports those entries as `duplicate` (first-write-wins
// per Apple dose) without touching the DB again. Fresh inserts register
// into the same map so an intra-batch repeat of a UUID dedups too. The
// partial `(user_id, external_id)` unique (migration 0239) is the race
// backstop.
const batchExternalIds = Array.from(
new Set(
entries
.map((e) => e.externalId)
.filter((id): id is string => id !== undefined),
),
);
const rowIdByExternalId = new Map<string, string>();
if (batchExternalIds.length > 0) {
const existingExternalRows = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
externalId: { in: batchExternalIds },
deletedAt: null,
},
select: { id: true, externalId: true },
});
for (const row of existingExternalRows) {
if (row.externalId) rowIdByExternalId.set(row.externalId, row.id);
}
}
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
// whole batch (it is user-scoped, not per-medication). Only read when
// at least one entry actually carries an `injectionSite`, so the hot
// sync path (no site) pays for zero extra round-trips.
const batchHasInjectionSite = entries.some(
(e) => e.injectionSite !== undefined,
);
let globalExcluded: InjectionSiteValue[] = [];
if (batchHasInjectionSite) {
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
});
globalExcluded = (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[];
}
const results: EntryResult[] = [];
let inserted = 0;
let updated = 0;
let duplicates = 0;
// External dose records kept as their own unscheduled dose because their
// band slot already carried a different one.
let extraDosesUnscheduled = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// Distinct `(medicationId, dayKey)` pairs touched by the batch, so one
// dose-day refresh fires per pair after all inserts complete. A per-row
// refresh would balloon a 500-entry batch into 500 sequential refreshes.
const touchedDays = new Map<
string,
{ medicationId: string; dayKey: string }
>();
// v1.17.1 (#22) — distinct `(medicationId, scheduledFor)` slots whose
// state actually changed in this batch (a row inserted / updated), so the
// silent cross-device sync fires once per affected slot — not once per
// row, and not for pending echoes / duplicates that changed nothing.
const syncSlots = new Map<
string,
{ medicationId: string; scheduledFor: string }
>();
// A landed non-skipped dose quiets an active snooze, exactly like the
// single-intake routes (they null `snoozedUntil` on every recorded take).
// Collected per medication here, cleared once after the loop.
const snoozeClearIds = new Set<string>();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!ownedSet.has(entry.medicationId)) {
const reason = "medication_not_found";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
// A record-only medication takes no new dose (iOS #116, item 15). The
// single-dose routes answer 422 `medication.intake.notTracked`; here the
// same decision is one entry's outcome, so the rest of the batch lands.
if (isRecordOnly(medById.get(entry.medicationId) ?? {})) {
const reason = INTAKE_NOT_TRACKED_REASON;
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
// Both dedup keys on this route are only idempotent while the value
// is STABLE across client launches: `(userId, externalId)` for the
// Apple dose event, and the `@unique` `idempotency_key` column for
// the replay probe below. A value that rotates per process (an
// object description carrying a memory address) never matches its
// own earlier row, so every re-sync logs the dose again. Refused per
// entry — one bad row must not stop the rest of the batch landing.
const unstable =
(entry.externalId === undefined
? null
: classifyExternalId(entry.externalId)) ??
(entry.idempotencyKey === undefined
? null
: classifyExternalId(entry.idempotencyKey));
if (unstable) {
unstableShapes.push(unstable);
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
});
continue;
}
// v1.28 — Apple dose-event replay: the batch pre-check found a live
// row already carrying this external UUID (or an earlier entry in
// THIS batch inserted it). First-write-wins per Apple dose — report
// `duplicate` with the existing row id so the sync cursor advances.
if (entry.externalId !== undefined) {
const existingRowId = rowIdByExternalId.get(entry.externalId);
if (existingRowId !== undefined) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: existingRowId });
continue;
}
}
try {
const incomingScheduledFor =
entry.scheduledFor ?? entry.takenAt ?? new Date();
// Classify the incoming write. An offline-sync replay echoes a
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
// user may already have actioned; that echo must NEVER clear a
// recorded `takenAt`. An explicit `takenAt` or `skipped:true` is a
// real user action and applies last-write-wins.
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
const isExplicitSkip = entry.skipped === true;
// v1.15.20 — taken writes attribute through the window-band engine
// (the SAME minter the read ledger + the compliance % consume), so a
// bulk-synced take binds to a slot identically to the single-route
// write. The optional `forceSlotInstant` pins an off-window take onto
// a named real slot. Pending echoes and skips carry no take to
// attribute by, so they keep the canonical anchor snap that binds
// them to the projector/worker-minted slot row.
let canonicalSlot: Date | null;
// v1.15.20 — binding provenance: USER_PIN on the forced path, AUTO on
// a band decision. Pending echoes / skips carry no decision
// (undefined → the upsert leaves an existing USER_PIN untouched).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
// Set when the band slot is held by a different dose and this take is
// recorded unscheduled; it must not converge onto any slot row below.
let heldByAnotherDose = false;
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
slotInstant: entry.forceSlotInstant,
});
if (canonicalSlot === null) {
// Mirrors the single route's 422 (`medications.intake.force_slot
// .invalid`) as a per-entry skip so one bad pin never fails the
// batch.
const reason = "force_slot_invalid";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
// v1.16.0 — refuse to pin onto a slot another recorded action
// already serves: the explicit-write last-write-wins rule would
// silently overwrite that dose record. Per-entry skip, mirroring
// the single route's 422 `medications.intake.force_slot.occupied`.
if (
await findPinConflict({
userId: user.id,
medicationId: entry.medicationId,
canonicalSlot,
incomingTakenAt: entry.takenAt ?? null,
})
) {
const reason = "force_slot_occupied";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
attributionSource = "USER_PIN";
} else if (isExplicitTaken) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
takenAt: entry.takenAt as Date,
});
canonicalSlot = attribution.slotInstant;
attributionSource = "AUTO";
// An external dose record (a HealthKit dose event) whose band slot
// already carries a different recorded dose is an extra dose, not a
// correction of that one. Converging would overwrite the slot's
// take last-write-wins and the extra dose would exist nowhere; it is
// recorded as its own unscheduled dose instead.
if (
canonicalSlot !== null &&
entry.externalId !== undefined &&
(await slotHeldByAnotherDose({
userId: user.id,
medicationId: entry.medicationId,
canonicalSlot,
takenAt: entry.takenAt as Date,
externalId: entry.externalId,
scheduledFor: entry.scheduledFor ?? null,
userTz: user.timezone,
}))
) {
canonicalSlot = null;
heldByAnotherDose = true;
extraDosesUnscheduled += 1;
}
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band
// membership on the skip moment, exactly like a take does. The
// defaulted-now snap below only reaches the tight reminder-grace
// window, so a skip tapped 20 minutes past the dose time inserted
// an orphan ad-hoc skip row while the slot's pending REMINDER row
// stayed open and later auto-missed — the user's deliberate skip
// recorded as a miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
} else {
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
// iOS posts the reminder actions here (source API); without this
// they inserted a SECOND row for a slot already carrying the
// projector/worker's pending REMINDER row (the unique key includes
// `source`, and the iOS-vs-server `scheduledFor` drifts by a
// minute). Snap onto the canonical slot instant and update the
// existing row in place.
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
incoming: incomingScheduledFor,
// Only a client-supplied `scheduledFor` names a real slot. A
// defaulted-now write must not snap across the wide ±halfGap
// window onto a far slot (phantom morning dose).
instantIsExplicit: entry.scheduledFor !== undefined,
// Pending echoes / skips never record a take; a pending sync
// echo legitimately maps to a future slot, so the taken
// future-slot guard stays off here.
isTakenWrite: false,
});
}
// The `scheduledFor` instant the row actually lands on — set by the
// branch that performs the write so the rollup recompute below keys
// the same day the stored row anchors to.
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
// v1.8.5 — resolve + validate the optional per-entry injection
// site. A disallowed site marks the entry skipped without failing
// the batch (per-entry contract); a non-injection / tracking-off /
// non-taken entry silently drops it.
let resolvedInjectionSite: InjectionSiteKey | null = null;
if (entry.injectionSite !== undefined) {
const med = medById.get(entry.medicationId);
const resolution = resolveInjectionSiteForWrite({
submitted: entry.injectionSite,
taken: isExplicitTaken,
deliveryForm: med?.deliveryForm ?? "ORAL",
trackInjectionSites: med?.trackInjectionSites ?? false,
allowedInjectionSites: (med?.allowedInjectionSites ??
[]) as InjectionSiteKey[],
globalExcludedInjectionSites: globalExcluded as InjectionSiteKey[],
});
if (resolution.kind === "disallowed") {
const reason = "injection_site_not_allowed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
resolvedInjectionSite = resolution.site;
}
if (canonicalSlot) {
// Scheduled dose — converge onto the one canonical slot row through
// the shared upsert: H1 deterministic selection, C2 no-downgrade
// guard, and a C1 race-safe create that re-finds + updates on a
// P2002 collision rather than misclassifying it as a duplicate and
// dropping the dose.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: entry.medicationId,
canonicalSlot,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
isExplicitTaken,
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
// v1.28 — an Apple dose-event import stamps its provenance on a
// fresh row; every other caller keeps today's "API".
createSource: entry.source ?? "API",
injectionSite: resolvedInjectionSite,
attributionSource,
// v1.16.4 — dose override only documents a consumed dose.
doseTaken: (!entry.skipped && entry.doseTaken) || null,
// v1.28 — external dose-event UUID rides onto the landed row.
externalId: entry.externalId ?? null,
});
if (applied.noDowngradeNoOp) {
// Pending echo onto an already-actioned slot. Report it as a
// duplicate so the iOS cursor advances WITHOUT downgrading the
// recorded dose. A pending echo is never a taken write, so it
// must never reach the inventory consume below.
duplicates += 1;
results.push({ index: i, status: "duplicate", id: applied.row.id });
} else if (applied.outcome === "updated") {
updated += 1;
results.push({ index: i, status: "updated", id: applied.row.id });
} else {
inserted += 1;
results.push({ index: i, status: "inserted", id: applied.row.id });
}
// v1.16.10 — inventory: only the genuine pending→taken
// transition consumes (the upsert's `consumedTransition` flag —
// the ONLY transition that may move stock). The stamp keeps a
// replayed batch exactly-once for post-v1.16.10 rows; the
// transition gate additionally protects pre-stamp rows: a full
// journal replay re-posting historical taken doses must not
// drain today's stock through their NULL stamps. An explicit
// skip refunds a previously-taken row's stamp. Pending echoes
// carry neither flag and never touch stock.
if (applied.consumedTransition && isExplicitTaken) {
await consumeForIntake({
client: prisma,
userId: user.id,
medicationId: entry.medicationId,
eventId: applied.row.id,
intakeAt: entry.takenAt as Date,
});
} else if (!applied.noDowngradeNoOp && isExplicitSkip) {
await restoreForIntake({
client: prisma,
userId: user.id,
eventId: applied.row.id,
});
}
} else {
// Resolver-null: PRN / off-slot / future-slot-guarded. Before the
// standalone insert, two guards keep the slot from forking into a
// second live row:
//
// 1. idempotencyKey replay pre-check — a re-submission returns the
// existing row as "duplicate" (previously reached via the P2002
// catch; the explicit probe keeps that contract now that a
// replay could otherwise converge as "updated" below).
// 2. source-agnostic convergence probe — when ANY live row already
// sits on the incoming instant (e.g. the pending REMINDER row
// the worker pre-minted on a slot the taken-write future guard
// refused to snap to), converge onto THAT row through the shared
// slot upsert instead of inserting a sibling that differs only
// by `source`. The partial unique index carries `source`, so it
// cannot catch this duplicate; the probe must.
if (entry.idempotencyKey) {
// Scoped to the caller. The key is client-supplied, so an
// unscoped read would resolve another user's row and hand its
// id back here (migration 0273 made the unique per user).
const replay = await prisma.medicationIntakeEvent.findUnique({
where: {
userId_idempotencyKey: {
userId: user.id,
idempotencyKey: entry.idempotencyKey,
},
},
select: { id: true },
});
if (replay) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: replay.id });
continue;
}
}
// Only an explicit client `scheduledFor` names a slot a row could
// already sit on; a defaulted anchor (takenAt / now) never does, so
// the probe is skipped on that path.
//
// Dose-safety guard: a TAKEN entry must not converge onto a slot
// whose anchor is in the future relative to the take. The resolver's
// forward guard already refused to snap a late take onto a future
// slot; the probe must honour the same invariant rather than binding
// the take forward onto that slot's pending REMINDER row (a
// late-morning dose consuming the evening slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
!heldByAnotherDose &&
entry.scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped: entry.skipped,
takenAt: entry.takenAt ?? null,
suppliedSlot: incomingScheduledFor,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor: incomingScheduledFor,
deletedAt: null,
},
select: { id: true },
})
: null;
if (existingSlotRow) {
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: entry.medicationId,
canonicalSlot: incomingScheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
isExplicitTaken,
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
// v1.28 — Apple provenance on a fresh row (see above).
createSource: entry.source ?? "API",
injectionSite: resolvedInjectionSite,
attributionSource,
doseTaken: (!entry.skipped && entry.doseTaken) || null,
// v1.28 — external dose-event UUID rides onto the landed row.
externalId: entry.externalId ?? null,
});
if (applied.noDowngradeNoOp) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: applied.row.id });
} else if (applied.outcome === "updated") {
updated += 1;
results.push({ index: i, status: "updated", id: applied.row.id });
} else {
inserted += 1;
results.push({ index: i, status: "inserted", id: applied.row.id });
}
// v1.16.10 — same inventory rule as the canonical-slot branch:
// gate on the upsert's pending→taken transition flag.
if (applied.consumedTransition && isExplicitTaken) {
await consumeForIntake({
client: prisma,
userId: user.id,
medicationId: entry.medicationId,
eventId: applied.row.id,
intakeAt: entry.takenAt as Date,
});
} else if (!applied.noDowngradeNoOp && isExplicitSkip) {
await restoreForIntake({
client: prisma,
userId: user.id,
eventId: applied.row.id,
});
}
} else {
// Genuinely standalone. Anchor the row on the intake instant —
// the documented ad-hoc contract (`scheduledFor = takenAt`) — so
// an unresolvable client anchor (a future slot instant the taken
// guard rejected) can never park a live row exactly on a slot the
// worker later mints a pending REMINDER row for. A pending echo
// (no takenAt) keeps the incoming instant. The idempotencyKey,
// when supplied, has a UNIQUE index; a racing re-submission
// returns the existing row via P2002 → status: "duplicate".
effectiveScheduledFor = entry.takenAt ?? incomingScheduledFor;
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor: effectiveScheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
// v1.28 — Apple provenance on a standalone (ad-hoc) row;
// every other caller keeps today's "API".
source: entry.source ?? "API",
idempotencyKey: entry.idempotencyKey ?? null,
// v1.28 — external dose-event UUID for the idempotent
// re-sync dedup.
...(entry.externalId !== undefined && {
externalId: entry.externalId,
}),
// v1.8.5 — site only on a resolved taken-injection entry.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
// v1.16.4 — dose override only on a taken entry carrying one.
...(!entry.skipped &&
entry.doseTaken && { doseTaken: entry.doseTaken }),
},
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
// v1.16.10 — a fresh standalone taken row consumes inventory
// units (a fresh skip / pending row has nothing to consume or
// refund).
if (isExplicitTaken) {
await consumeForIntake({
client: prisma,
userId: user.id,
medicationId: entry.medicationId,
eventId: row.id,
intakeAt: entry.takenAt as Date,
});
}
}
}
const dayKey = dayKeyForScheduledFor(
effectiveScheduledFor,
user.timezone,
);
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
dayKey,
});
// v1.17.1 (#22) — collect the slot for the silent cross-device sync
// only when the row's state actually changed (inserted / updated). A
// pending echo, duplicate, or no-downgrade no-op leaves the visible
// dose state untouched, so it must not wake the user's other devices.
// v1.28 — register the landed row under its external dose-event
// UUID so a repeat of the same UUID later in THIS batch dedups
// in-memory instead of tripping the partial unique.
const landed = results[results.length - 1];
if (entry.externalId !== undefined && landed?.id !== undefined) {
rowIdByExternalId.set(entry.externalId, landed.id);
}
const lastStatus = results[results.length - 1]?.status;
if (lastStatus === "inserted" || lastStatus === "updated") {
const scheduledForIso = effectiveScheduledFor.toISOString();
syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, {
medicationId: entry.medicationId,
scheduledFor: scheduledForIso,
});
if (!entry.skipped) snoozeClearIds.add(entry.medicationId);
}
} catch (err: unknown) {
// P2002 = unique-constraint violation. Two shapes reach here:
// 1. an idempotencyKey collision on the unscheduled/PRN insert →
// "duplicate" (the canonical-slot path already absorbs its own
// same-slot P2002 race inside `applyCanonicalSlotWrite`);
// 2. a same-slot collision on the unscheduled insert. In both
// cases we surface the existing row id so the cursor advances.
if (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002"
) {
const existing = entry.idempotencyKey
? await prisma.medicationIntakeEvent.findUnique({
where: {
userId_idempotencyKey: {
userId: user.id,
idempotencyKey: entry.idempotencyKey,
},
},
select: { id: true },
})
: // v1.28 — an Apple entry that raced the partial
// `(user_id, external_id)` unique (concurrent batches syncing
// the same dose event) resolves to the winning live row.
entry.externalId
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
externalId: entry.externalId,
deletedAt: null,
},
select: { id: true },
})
: null;
duplicates += 1;
results.push({
index: i,
status: "duplicate",
id: existing?.id,
});
continue;
}
const reason =
err instanceof Error ? err.message.slice(0, 120) : "create_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
await auditLog("medications.intake.bulk.ingest", {
userId: user.id,
const outcome = await ingestBulkIntake({
user,
scoped,
entries,
syncTrigger,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
originDeviceToken: request.headers.get("x-device-id"),
});
annotate({
action: { name: "medications.intake.bulk.ingest" },
meta: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
extra_doses_unscheduled: extraDosesUnscheduled,
...(unstableShapes.length > 0
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
: {}),
},
});
// v1.4.34 — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch. v1.16.8 — hard-evict: this bulk endpoint is the iOS
// user's INTERACTIVE intake path (a dose taken / skipped on the phone
// syncs through here), so the very next read must show the user their
// own action — a marked-stale SWR cell would hand back the pre-dose
// payload. The genuinely background writers (the auto-miss cron in
// `intake-auto-skip.ts`, slot dedup in `intake-slot-dedup.ts`) keep
// the default mark-stale at their own call sites.
if (inserted > 0 || updated > 0) {
invalidateUserMedications(user.id, { evict: true });
if (outcome.kind === "refused") {
return apiError(outcome.message, 422, { errorCode: outcome.errorCode });
}
// Refresh the derived rows of each distinct `(medicationId, dayKey)` the
// batch touched. Best-effort: the refresh reports its own failures and
// never fails the ingest.
for (const { medicationId, dayKey } of touchedDays.values()) {
await refreshDoseDay({
userId: user.id,
medicationId,
dayKey,
tz: user.timezone,
});
}
// v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out
// for the whole batch regardless of how many slots it touched (the
// per-user coalescer folds bursts further), excluding the originating
// device (`X-Device-Id` = registered `Device.token`). APNs-only,
// best-effort, fire-and-forget: the canonical rows are already
// persisted, so a sync-push miss never affects the batch response.
// The single-intake routes reset a medication's snooze on every recorded
// take; a drained offline batch must quiet the snooze the same way, or a
// dose synced in later keeps counting as snoozed although it was taken.
if (snoozeClearIds.size > 0) {
await prisma.medication.updateMany({
where: { id: { in: [...snoozeClearIds] }, userId: user.id },
data: { snoozedUntil: null },
});
}
if (syncSlots.size > 0) {
queueMedicationIntakeSync({
userId: user.id,
originDeviceToken: request.headers.get("x-device-id"),
});
// v1.18.4 — PWA-only counterpart: close the still-pending dose-due
// reminders for every affected slot over Web Push and refresh the app
// badge once with the post-batch outstanding-dose count. Best-effort,
// fire-and-forget — a clear miss never affects the batch response.
const affectedSlots = Array.from(syncSlots.values());
void (async () => {
const badgeCount = await countOutstandingDosesToday(
user.id,
user.timezone,
);
await dispatchMedicationIntakeWebClearBulk({
userId: user.id,
slots: affectedSlots,
badgeCount,
});
})();
}
// v1.42.4 — Apple Health doses get the per-type arrival record the
// measurement batch keeps for its types: when they last arrived, under which
// trigger, when a sync last brought a new or changed dose, and when a
// background sync last carried them. Entries sent with `source:
// "APPLE_HEALTH"` only; a dose taken in the app is not an Apple Health
// arrival. Replays answered `duplicate` alone are not one (#1259), the
// same rule as the batch route.
const doseArrival = { accepted: 0, newSamples: 0 };
for (const result of results) {
if (entries[result.index]?.source !== "APPLE_HEALTH") continue;
if (result.status === "skipped") continue;
doseArrival.accepted += 1;
if (result.status !== "duplicate") doseArrival.newSamples += 1;
}
if (doseArrival.accepted > 0 && !scoped) {
await recordHealthKitTypeArrivals(prisma, {
userId: user.id,
counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),
trigger: syncTrigger ?? null,
});
}
// The SyncMode checkpoint belongs to the push that moved the data, not to
// the `/api/sync/state` handshake that reports it. This endpoint is one of
// the three domains that handshake summarises, so an intake drain advances
// it. A `backfill` catch-up does not: it is history, not a sync.
if (syncTrigger !== BACKFILL_SYNC_TRIGGER) {
await markSyncCheckpoint(user.id);
}
return apiSuccess({
processed: entries.length,
inserted,
updated,
duplicates,
skipped,
entries: results,
});
return apiSuccess(outcome.body);
}
+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);
});