mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
refactor(medications): move the per-medication dose write and history out of their route
POST and GET /api/medications/{id}/intake keep authentication, the intake and ownership guards, the body and query parsing with their refusals, the delegated outcome-change refusal and the HTTP mapping. The dose write, one 607-line function, moves to lib/medications/record-intake/ in the order it always ran: the injection-site check and the slot attribution (attribution.ts), the replay and dedup answers and the slot, probe or standalone write (write.ts), then the inventory, the audit row, the rollup, the lifecycle and the tails (record-intake.ts). The history list moves to list-intake-events.ts. The route falls from 767 to 200 lines; no function in the new modules is over 75.
The parity walk recorded before the move (medication-intake.parity) is unchanged. The activity-verb guard now reads the awaited audit actions of a delegable write's handler modules beside the route, for this route and for the medication create that moved before it, and holds each module to its route's imports; the sharing guard, the intake-tracking allowlist and the dense-read guard follow the moved code.
This commit is contained in:
1 parent
af53d3d6f0
commit
c4ee519012
9 files changed
+972
-622
No files matched your search
@@ -72,6 +72,8 @@ const SCOPED_DIRS = [
|
||||
"lib/medications/read-medication.ts",
|
||||
"lib/medications/update-medication/",
|
||||
"lib/medications/delete-medication.ts",
|
||||
"lib/medications/record-intake/",
|
||||
"lib/medications/list-intake-events.ts",
|
||||
];
|
||||
|
||||
/** Types a device writes at sampling rate rather than a few times a day. */
|
||||
|
||||
@@ -66,8 +66,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
|
||||
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
|
||||
"lib/ai/coach/tools/availability.ts":
|
||||
"whether any intake history exists, and its date range",
|
||||
"app/api/medications/[id]/intake/route.ts":
|
||||
"one medication's recorded history, listed or written",
|
||||
"lib/medications/list-intake-events.ts":
|
||||
"one medication's recorded history, listed",
|
||||
"app/api/medications/[id]/intake/bulk-delete/route.ts":
|
||||
"deletes rows the person chose",
|
||||
"lib/medications/update-medication/write.ts":
|
||||
|
||||
@@ -439,6 +439,9 @@ const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
|
||||
"lib/medications/update-medication/update-medication.ts",
|
||||
"lib/medications/delete-medication.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/route.ts": [
|
||||
"lib/medications/record-intake/record-intake.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": [
|
||||
"lib/medications/update-intake-event.ts",
|
||||
"lib/medications/delete-intake-event.ts",
|
||||
|
||||
@@ -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 } });
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* One medication's intake history: `GET /api/medications/{id}/intake`.
|
||||
*
|
||||
* The route authenticates (READ over the `medications` section), runs the
|
||||
* ownership guard and parses the query; the filter, the order, the page and
|
||||
* the count are built here.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { listIntakeEventsSchema } from "@/lib/validations/medication";
|
||||
|
||||
export type IntakeEventListQuery = z.output<typeof listIntakeEventsSchema>;
|
||||
|
||||
export async function listIntakeEvents(input: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
query: IntakeEventListQuery;
|
||||
}) {
|
||||
const { userId, medicationId: id } = input;
|
||||
const { limit, offset, sortBy, sortDir, status } = input.query;
|
||||
|
||||
// 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 },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
// Exclude tombstoned rows from the per-medication
|
||||
// intake history list + its count.
|
||||
const where = {
|
||||
medicationId: id,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...statusFilter,
|
||||
};
|
||||
|
||||
// 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,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.intake.list" },
|
||||
meta: { medication_id: id, total, status },
|
||||
});
|
||||
|
||||
return { events, meta: { total, limit, offset } };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Which slot a dose posted to `POST /api/medications/{id}/intake` binds to,
|
||||
* and the binding provenance it records.
|
||||
*
|
||||
* Window-band slot attribution (replacing 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.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import {
|
||||
findPinConflict,
|
||||
mayConvergeOntoSuppliedSlot,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
|
||||
/** A coded 422 refusal the route answers with `apiError`. */
|
||||
export interface IntakeWriteRefusal {
|
||||
kind: "refused";
|
||||
message: string;
|
||||
errorCode: string;
|
||||
}
|
||||
|
||||
export type AttributionSource = "AUTO" | "USER_PIN";
|
||||
|
||||
export interface IntakeAttribution {
|
||||
kind: "attributed";
|
||||
canonicalSlot: Date | null;
|
||||
/**
|
||||
* USER_PIN on the forced "diesem Slot zuordnen" path, AUTO when band
|
||||
* attribution decided. Skips carry no binding decision (undefined → column
|
||||
* untouched/default).
|
||||
*/
|
||||
attributionSource: AttributionSource | undefined;
|
||||
}
|
||||
|
||||
interface AttributionInput {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
medicationId: string;
|
||||
skipped: boolean;
|
||||
scheduledFor: Date | undefined;
|
||||
forceSlotInstant: Date | undefined;
|
||||
resolvedTakenAt: Date | null;
|
||||
incomingScheduledFor: Date;
|
||||
}
|
||||
|
||||
async function attributeSkip(input: AttributionInput): Promise<Date | null> {
|
||||
const { user, medicationId: id, scheduledFor, incomingScheduledFor } = input;
|
||||
if (scheduledFor === undefined) {
|
||||
// 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,
|
||||
});
|
||||
return attribution.slotInstant;
|
||||
}
|
||||
return resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
instantIsExplicit: true,
|
||||
isTakenWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function attributePin(
|
||||
input: AttributionInput,
|
||||
forceSlotInstant: Date,
|
||||
): Promise<IntakeAttribution | IntakeWriteRefusal> {
|
||||
const { user, medicationId: id, resolvedTakenAt } = input;
|
||||
const canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
slotInstant: forceSlotInstant,
|
||||
});
|
||||
if (canonicalSlot === null) {
|
||||
annotate({
|
||||
action: { name: "medication.intake.force_slot.invalid" },
|
||||
meta: { medication_id: id },
|
||||
});
|
||||
return {
|
||||
kind: "refused",
|
||||
message: "forceSlotInstant is not a scheduled slot of this medication",
|
||||
errorCode: "medications.intake.force_slot.invalid",
|
||||
};
|
||||
}
|
||||
// Refuse to pin onto a slot another recorded action already
|
||||
// serves: 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 {
|
||||
kind: "refused",
|
||||
message: "forceSlotInstant already carries a recorded dose action",
|
||||
errorCode: "medications.intake.force_slot.occupied",
|
||||
};
|
||||
}
|
||||
return { kind: "attributed", canonicalSlot, attributionSource: "USER_PIN" };
|
||||
}
|
||||
|
||||
async function attributeTake(
|
||||
input: AttributionInput,
|
||||
): Promise<IntakeAttribution> {
|
||||
const {
|
||||
user,
|
||||
medicationId: id,
|
||||
skipped,
|
||||
scheduledFor,
|
||||
resolvedTakenAt,
|
||||
incomingScheduledFor,
|
||||
} = input;
|
||||
// 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,
|
||||
});
|
||||
let 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.
|
||||
let attributionSource: 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 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";
|
||||
}
|
||||
}
|
||||
return { kind: "attributed", canonicalSlot, attributionSource };
|
||||
}
|
||||
|
||||
/** The slot the dose binds to, or the coded refusal of a bad pin. */
|
||||
export async function resolveIntakeAttribution(
|
||||
input: AttributionInput,
|
||||
): Promise<IntakeAttribution | IntakeWriteRefusal> {
|
||||
if (input.skipped) {
|
||||
return {
|
||||
kind: "attributed",
|
||||
canonicalSlot: await attributeSkip(input),
|
||||
attributionSource: undefined,
|
||||
};
|
||||
}
|
||||
if (input.forceSlotInstant !== undefined) {
|
||||
return attributePin(input, input.forceSlotInstant);
|
||||
}
|
||||
return attributeTake(input);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Record one dose of one medication: `POST /api/medications/{id}/intake`.
|
||||
*
|
||||
* The route authenticates (WRITE over the `medications` section), runs the
|
||||
* intake guard, parses the body and answers the schema refusal and the
|
||||
* delegated outcome-change refusal; the injection-site check, the slot
|
||||
* attribution, the replay and dedup answers, the write and every tail after
|
||||
* it run here, in the order they always ran.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
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 type { intakeSchema } from "@/lib/validations/medication";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { 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 {
|
||||
resolveIntakeAttribution,
|
||||
type IntakeWriteRefusal,
|
||||
} from "./attribution";
|
||||
import {
|
||||
findReplayedIntake,
|
||||
writeIntake,
|
||||
type IntakeWriteResult,
|
||||
} from "./write";
|
||||
|
||||
export type IntakeInput = z.output<typeof intakeSchema>;
|
||||
|
||||
type WrittenIntake = Extract<IntakeWriteResult, { kind: "written" }>;
|
||||
|
||||
export type RecordIntakeOutcome =
|
||||
| IntakeWriteRefusal
|
||||
| Exclude<IntakeWriteResult, WrittenIntake>
|
||||
| { kind: "recorded"; event: WrittenIntake["event"] };
|
||||
|
||||
interface RecordIntakeContext {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
actorId: string;
|
||||
medicationId: string;
|
||||
intakeSource: "API" | "WEB";
|
||||
isDelegated: boolean;
|
||||
ipAddress: string | null;
|
||||
originDeviceToken: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function resolveInjectionSite(
|
||||
ctx: RecordIntakeContext,
|
||||
data: IntakeInput,
|
||||
): Promise<
|
||||
{ kind: "site"; site: InjectionSiteKey | null } | IntakeWriteRefusal
|
||||
> {
|
||||
const { user, medicationId: id } = ctx;
|
||||
const { injectionSite, skipped } = data;
|
||||
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 {
|
||||
kind: "refused",
|
||||
message: "Injection site is not allowed for this medication",
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
};
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
return { kind: "site", site: resolvedInjectionSite };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function settleInventory(
|
||||
ctx: RecordIntakeContext,
|
||||
skipped: boolean,
|
||||
written: WrittenIntake,
|
||||
): Promise<void> {
|
||||
const { user, medicationId: id } = ctx;
|
||||
const { event, consumedTransition } = written;
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The audit row, the event, the caches, the rollup and the lifecycle. */
|
||||
async function recordAfterWrite(
|
||||
ctx: RecordIntakeContext,
|
||||
skipped: boolean,
|
||||
event: WrittenIntake["event"],
|
||||
): Promise<void> {
|
||||
const { user, medicationId: id } = ctx;
|
||||
await auditLog("medication.intake", {
|
||||
userId: user.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
details: {
|
||||
medicationId: id,
|
||||
eventId: event.id,
|
||||
skipped,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.intake",
|
||||
entity_type: "intake_event",
|
||||
entity_id: event.id,
|
||||
},
|
||||
meta: {
|
||||
medication_id: id,
|
||||
skipped,
|
||||
},
|
||||
});
|
||||
|
||||
// Bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the dose event.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
/** The tails that never affect the response. */
|
||||
function startTails(
|
||||
ctx: RecordIntakeContext,
|
||||
skipped: boolean,
|
||||
event: WrittenIntake["event"],
|
||||
): void {
|
||||
const { user, medicationId: id } = ctx;
|
||||
// (#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: ctx.originDeviceToken,
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
})();
|
||||
|
||||
// "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: ctx.actorId,
|
||||
medicationId: id,
|
||||
status: skipped ? "skipped" : "taken",
|
||||
});
|
||||
}
|
||||
|
||||
/** Record the dose the route parsed. */
|
||||
export async function recordIntake(
|
||||
ctx: RecordIntakeContext,
|
||||
data: IntakeInput,
|
||||
): Promise<RecordIntakeOutcome> {
|
||||
const { user, medicationId: id } = ctx;
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey, doseTaken } = data;
|
||||
|
||||
// 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;
|
||||
|
||||
const site = await resolveInjectionSite(ctx, data);
|
||||
if (site.kind === "refused") return site;
|
||||
|
||||
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
|
||||
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
|
||||
|
||||
const attribution = await resolveIntakeAttribution({
|
||||
user,
|
||||
medicationId: id,
|
||||
skipped,
|
||||
scheduledFor,
|
||||
forceSlotInstant: data.forceSlotInstant,
|
||||
resolvedTakenAt,
|
||||
incomingScheduledFor,
|
||||
});
|
||||
if (attribution.kind === "refused") return attribution;
|
||||
|
||||
const write = {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
canonicalSlot: attribution.canonicalSlot,
|
||||
attributionSource: attribution.attributionSource,
|
||||
scheduledFor,
|
||||
skipped,
|
||||
idempotencyKey,
|
||||
resolvedTakenAt,
|
||||
incomingScheduledFor,
|
||||
intakeSource: ctx.intakeSource,
|
||||
isDelegated: ctx.isDelegated,
|
||||
resolvedInjectionSite: site.site,
|
||||
resolvedDoseTaken,
|
||||
};
|
||||
const replayed = await findReplayedIntake(write);
|
||||
if (replayed) return { kind: "existing", row: replayed };
|
||||
|
||||
const written = await writeIntake(write);
|
||||
if (written.kind !== "written") return written;
|
||||
|
||||
await settleInventory(ctx, skipped, written);
|
||||
await recordAfterWrite(ctx, skipped, written.event);
|
||||
startTails(ctx, skipped, written.event);
|
||||
|
||||
return { kind: "recorded", event: written.event };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* The row a dose posted to `POST /api/medications/{id}/intake` lands on: the
|
||||
* replay and dedup answers, the canonical slot write, the convergence probe
|
||||
* onto a row already on the named instant, or the standalone insert.
|
||||
*/
|
||||
import type { MedicationIntakeEvent } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
mayConvergeOntoSuppliedSlot,
|
||||
type ApplyCanonicalSlotWriteResult,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import type { AttributionSource } from "./attribution";
|
||||
|
||||
type IntakeRow = MedicationIntakeEvent | ApplyCanonicalSlotWriteResult["row"];
|
||||
|
||||
export interface IntakeWrite {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
canonicalSlot: Date | null;
|
||||
attributionSource: AttributionSource | undefined;
|
||||
scheduledFor: Date | undefined;
|
||||
skipped: boolean;
|
||||
idempotencyKey: string | undefined;
|
||||
resolvedTakenAt: Date | null;
|
||||
incomingScheduledFor: Date;
|
||||
intakeSource: "API" | "WEB";
|
||||
isDelegated: boolean;
|
||||
resolvedInjectionSite: InjectionSiteKey | null;
|
||||
resolvedDoseTaken: string | null;
|
||||
}
|
||||
|
||||
export type IntakeWriteResult =
|
||||
| { kind: "existing"; row: MedicationIntakeEvent }
|
||||
| { kind: "outcome_change_refused" }
|
||||
| { kind: "written"; event: IntakeRow; consumedTransition: boolean };
|
||||
|
||||
/**
|
||||
* Idempotency check (explicit key or server-side dedup window). The row a
|
||||
* replay or a double-log answers with, or null when the dose is new.
|
||||
*/
|
||||
export async function findReplayedIntake(
|
||||
write: IntakeWrite,
|
||||
): Promise<MedicationIntakeEvent | null> {
|
||||
const { userId, medicationId: id, idempotencyKey, skipped } = write;
|
||||
if (idempotencyKey) {
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
idempotencyKey,
|
||||
userId,
|
||||
medicationId: id,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
} else if (!write.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,
|
||||
medicationId: id,
|
||||
skipped,
|
||||
createdAt: { gte: new Date(Date.now() - 60_000) },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (recentDuplicate) {
|
||||
return recentDuplicate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converge onto the one 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 500-ing or duplicating.
|
||||
*/
|
||||
async function writeOntoSlot(
|
||||
write: IntakeWrite,
|
||||
canonicalSlot: Date,
|
||||
): Promise<IntakeWriteResult> {
|
||||
const { userId, medicationId: id, skipped } = write;
|
||||
// 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;
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId,
|
||||
medicationId: id,
|
||||
canonicalSlot,
|
||||
takenAt: write.resolvedTakenAt,
|
||||
skipped,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: write.idempotencyKey ?? null,
|
||||
createSource: write.intakeSource,
|
||||
refuseOutcomeChange: write.isDelegated,
|
||||
// Resolved + validated site (null unless a tracking-on
|
||||
// injection taken write supplied an allowed site).
|
||||
injectionSite: write.resolvedInjectionSite,
|
||||
attributionSource: write.attributionSource,
|
||||
// Per-intake dose override (null unless a taken write
|
||||
// carried one).
|
||||
doseTaken: write.resolvedDoseTaken,
|
||||
});
|
||||
if (applied.outcomeChangeRefused) return { kind: "outcome_change_refused" };
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "written",
|
||||
event: applied.row,
|
||||
consumedTransition: applied.consumedTransition,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
async function insertStandalone(write: IntakeWrite): Promise<IntakeRow> {
|
||||
const {
|
||||
userId,
|
||||
medicationId: id,
|
||||
skipped,
|
||||
resolvedTakenAt,
|
||||
resolvedInjectionSite,
|
||||
resolvedDoseTaken,
|
||||
} = write;
|
||||
const [event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
medicationId: id,
|
||||
scheduledFor: resolvedTakenAt ?? write.incomingScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
source: write.intakeSource,
|
||||
idempotencyKey: write.idempotencyKey ?? null,
|
||||
// Site only on a resolved taken-injection write.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
// 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 },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Write the dose; the caller already answered a replay. */
|
||||
export async function writeIntake(
|
||||
write: IntakeWrite,
|
||||
): Promise<IntakeWriteResult> {
|
||||
const { userId, medicationId: id, skipped, scheduledFor } = write;
|
||||
if (write.canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row regardless
|
||||
// of `source` (the pending REMINDER row, or any prior row for this
|
||||
// slot).
|
||||
return writeOntoSlot(write, write.canonicalSlot);
|
||||
}
|
||||
// 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: write.resolvedTakenAt,
|
||||
suppliedSlot: write.incomingScheduledFor,
|
||||
})
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
medicationId: id,
|
||||
scheduledFor: write.incomingScheduledFor,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (existingSlotRow) {
|
||||
return writeOntoSlot(write, write.incomingScheduledFor);
|
||||
}
|
||||
// For the unscheduled/PRN branch a non-skip write always records a fresh
|
||||
// dose, so it consumes when not skipped.
|
||||
return {
|
||||
kind: "written",
|
||||
event: await insertStandalone(write),
|
||||
consumedTransition: !skipped,
|
||||
};
|
||||
}
|
||||
@@ -244,12 +244,64 @@ describe("the matcher sees what it looks for", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Delegable write routes whose handler bodies live in service modules under
|
||||
* `src/lib/`, keyed by the route relative to `src/`. The route keeps the
|
||||
* resolver call, so membership is read off the route; the awaited audit
|
||||
* actions are read off the route AND these modules, because that is where the
|
||||
* rows are written now. Each module has to be imported by its route (leg
|
||||
* below), so an entry cannot outlive the delegation it describes.
|
||||
*/
|
||||
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
|
||||
"app/api/medications/route.ts": ["lib/medications/create-medication.ts"],
|
||||
"app/api/medications/[id]/intake/route.ts": [
|
||||
"lib/medications/record-intake/record-intake.ts",
|
||||
],
|
||||
};
|
||||
|
||||
function handlerAuditActions(file: string): string[] {
|
||||
const rel = relative(join(process.cwd(), "src"), file);
|
||||
return (HANDLER_BODY_MODULES[rel] ?? []).flatMap((body) => {
|
||||
const path = join(process.cwd(), "src", body);
|
||||
return moduleFacts(readFileSync(path, "utf8"), path).awaitedAuditActions;
|
||||
});
|
||||
}
|
||||
|
||||
describe("the owner's activity view names what a delegate did", () => {
|
||||
const routes = routeFiles(API);
|
||||
const writeModules = routes
|
||||
.map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) }))
|
||||
.map((file) => {
|
||||
const facts = moduleFacts(readFileSync(file, "utf8"), file);
|
||||
return {
|
||||
file,
|
||||
writesToRecord: facts.writesToRecord,
|
||||
awaitedAuditActions: [
|
||||
...facts.awaitedAuditActions,
|
||||
...handlerAuditActions(file),
|
||||
],
|
||||
};
|
||||
})
|
||||
.filter((m) => m.writesToRecord);
|
||||
|
||||
it("reads each moved handler body where its route imports it from", () => {
|
||||
const entries = Object.entries(HANDLER_BODY_MODULES);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
for (const [route, modules] of entries) {
|
||||
const source = readFileSync(join(process.cwd(), "src", route), "utf8");
|
||||
expect(moduleFacts(source, route).writesToRecord, route).toBe(true);
|
||||
for (const body of modules) {
|
||||
const specifier = `@/${body.replace(/\.ts$/, "")}`;
|
||||
expect(source, `${route} does not import ${specifier}`).toContain(
|
||||
`"${specifier}"`,
|
||||
);
|
||||
expect(
|
||||
handlerAuditActions(join(process.cwd(), "src", route)).length,
|
||||
`${body} awaits no audit row`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a plausible number of route modules", () => {
|
||||
// A walk that finds nothing agrees with a map that covers nothing.
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
|
||||
Reference in new issue
Block a user