mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'feat/v146-server-meds2' into feat/v146-server
# Conflicts: # src/__tests__/external-id-stability-guard.test.ts
This commit is contained in:
commit
40eb1255b7
148 files changed
+96447
-3191
No files matched your search
@@ -69,6 +69,15 @@ const SCOPED_DIRS = [
|
||||
"lib/measurements/batch-ingest/",
|
||||
"lib/measurements/create/",
|
||||
"lib/measurements/list/measurement-list.ts",
|
||||
"lib/medications/read-medication.ts",
|
||||
"lib/medications/update-medication/",
|
||||
"lib/medications/delete-medication.ts",
|
||||
"lib/medications/record-intake/",
|
||||
"lib/medications/list-intake-events.ts",
|
||||
"lib/medications/intake-today.ts",
|
||||
"lib/medications/intake-compliance.ts",
|
||||
"lib/medications/update-intake-status/",
|
||||
"lib/medications/intake-bulk/",
|
||||
];
|
||||
|
||||
/** Types a device writes at sampling rate rather than a few times a day. */
|
||||
|
||||
@@ -365,7 +365,7 @@ describe("the intake idempotency key is never read across users", () => {
|
||||
const found = intakeQueriesFilteringOnTheKey();
|
||||
expect(found.length).toBeGreaterThanOrEqual(6);
|
||||
expect(found.map((f) => f.file)).toContain(
|
||||
"app/api/medications/intake/bulk/route.ts",
|
||||
"lib/medications/intake-bulk/entry-write.ts",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -403,7 +403,10 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
|
||||
"lib/measurements/batch-ingest/ingest.ts",
|
||||
],
|
||||
"app/api/mood-entries/bulk/route.ts": ["lib/mood/bulk-ingest.ts"],
|
||||
"app/api/medications/intake/bulk/route.ts": [],
|
||||
"app/api/medications/intake/bulk/route.ts": [
|
||||
"lib/medications/intake-bulk/entry.ts",
|
||||
"lib/medications/intake-bulk/ingest.ts",
|
||||
],
|
||||
"app/api/workouts/batch/route.ts": [
|
||||
"lib/workouts/batch-ingest/entries.ts",
|
||||
"lib/workouts/batch-ingest/ingest.ts",
|
||||
|
||||
@@ -309,9 +309,44 @@ function actionsReachableFrom(
|
||||
out.push(...packageActions(reexported, read));
|
||||
}
|
||||
}
|
||||
for (const body of HANDLER_BODY_MODULES[relative(SRC, file)] ?? []) {
|
||||
out.push(...actionsReachableFrom(join(SRC, body), read));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegated routes whose handler bodies live in service modules under
|
||||
* `src/lib/`, keyed by the route relative to `src/`. A handler body is the
|
||||
* route's own code moved, so it counts at the route's level: its actions and
|
||||
* those of its direct imports (the medication lifecycle a dose write
|
||||
* reconciles through) are reached as they were before the move. Each module
|
||||
* has to be a direct import of 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]/route.ts": [
|
||||
"lib/medications/read-medication.ts",
|
||||
"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",
|
||||
"lib/medications/list-intake-events.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": [
|
||||
"lib/medications/update-intake-event.ts",
|
||||
"lib/medications/delete-intake-event.ts",
|
||||
],
|
||||
"app/api/medications/intake/route.ts": [
|
||||
"lib/medications/intake-today.ts",
|
||||
"lib/medications/intake-compliance.ts",
|
||||
"lib/medications/update-intake-status/admission.ts",
|
||||
"lib/medications/update-intake-status/update-intake-status.ts",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The actions of a re-exported module together with the sibling modules it
|
||||
* imports from its own folder. The re-exported module is a package's public
|
||||
@@ -553,6 +588,21 @@ describe("every delegated action has a sentence of its own", () => {
|
||||
expect(manageOnly).not.toContain(anchor);
|
||||
});
|
||||
|
||||
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, bodies] of entries) {
|
||||
const file = join(SRC, route);
|
||||
expect(delegatedModules, route).toContain(file);
|
||||
for (const body of bodies) {
|
||||
expect(
|
||||
read(file).imports,
|
||||
`${route} does not import ${body}`,
|
||||
).toContain(join(SRC, body));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reaches the actions a route files through what it imports", () => {
|
||||
// Both halves of the fix, pinned against the two that were invisible.
|
||||
expect(actions).toContain("medication.intake.import.kickoff.denied");
|
||||
|
||||
@@ -68,13 +68,13 @@ 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",
|
||||
"app/api/medications/[id]/route.ts":
|
||||
"one medication's slots; tombstones on the tracking switch",
|
||||
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
|
||||
"lib/medications/update-medication/write.ts":
|
||||
"one medication's open slots, tombstoned on a schedule replace or the tracking switch",
|
||||
"lib/medications/intake-bulk/ingest.ts": "writes recorded doses",
|
||||
"lib/medications/scheduling/slot-upsert.ts":
|
||||
"binds a recorded dose to its slot",
|
||||
"lib/medications/scheduling/worker-helpers.ts":
|
||||
@@ -115,6 +115,14 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
"units consumed by a recorded dose",
|
||||
"lib/medications/intake-slot-dedup.ts":
|
||||
"folds duplicate rows of doses already recorded",
|
||||
// The medication edit: the schedule count and the primary schedule's id
|
||||
// for the reminder-window bridge, and the replaced rows archived as an era
|
||||
// whose payload is empty for a stretch with tracking off. Nothing due comes
|
||||
// out of either; the edit answers through scheduleWireFields.
|
||||
"lib/medications/update-medication/preflight.ts":
|
||||
"schedule count and the primary schedule's id, derives nothing due",
|
||||
"lib/medications/update-medication/write.ts":
|
||||
"archives the replaced schedule; the era payload follows the tracking switch",
|
||||
// Readers that hand the rows to a builder whose input TYPE requires
|
||||
// `trackIntake` and which gates on it: the per-medication compliance
|
||||
// payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor
|
||||
|
||||
@@ -435,6 +435,17 @@ function callsSymbol(rel: string, symbol: string): boolean {
|
||||
*/
|
||||
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
|
||||
"app/api/medications/route.ts": ["lib/medications/create-medication.ts"],
|
||||
"app/api/medications/[id]/route.ts": [
|
||||
"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/intake/route.ts": [
|
||||
"lib/medications/update-intake-status/admission.ts",
|
||||
"lib/medications/update-intake-status/update-intake-status.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": [
|
||||
"lib/medications/update-intake-event.ts",
|
||||
"lib/medications/delete-intake-event.ts",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -83,7 +83,7 @@ describe("sendsMedicationReminders", () => {
|
||||
}
|
||||
for (const dto of [
|
||||
"lib/medications/list-read.ts",
|
||||
"app/api/medications/[id]/route.ts",
|
||||
"lib/medications/read-medication.ts",
|
||||
]) {
|
||||
expect(src(dto), dto).toMatch(
|
||||
/remindersActive:\s*sendsMedicationReminders\(/,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Delete one medication: `DELETE /api/medications/{id}`.
|
||||
*
|
||||
* The route authenticates (the caller's own account) and runs the ownership
|
||||
* guard; the token revocation, the delete, its audit row and its tails run
|
||||
* here, in the order they always ran.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
enqueueSupplementNutrientRecompute,
|
||||
medicationHasComposition,
|
||||
} from "@/lib/medications/ingredients/recompute-job";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { deleteMedicationCategory } from "@/lib/medication-category";
|
||||
|
||||
/**
|
||||
* The name the audit row carries, read after the ownership guard passed.
|
||||
* Null for the route's 404.
|
||||
*/
|
||||
export async function findMedicationForDelete(
|
||||
id: string,
|
||||
): Promise<{ name: string } | null> {
|
||||
return prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { name: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMedication(input: {
|
||||
userId: string;
|
||||
id: string;
|
||||
existing: { name: string };
|
||||
ipAddress: string | null;
|
||||
}): Promise<void> {
|
||||
const { userId, id, existing } = input;
|
||||
|
||||
// Revoke API tokens scoped to this medication
|
||||
const medicationScope = `medication:${id}:ingest`;
|
||||
await prisma.apiToken.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revoked: false,
|
||||
permissions: { has: medicationScope },
|
||||
},
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
// Whether its doses counted toward the micronutrients, read
|
||||
// before the delete cascades the composition away.
|
||||
const hadComposition = await medicationHasComposition(userId, id);
|
||||
|
||||
await deleteMedicationCategory(id);
|
||||
await prisma.medication.delete({ where: { id } });
|
||||
|
||||
await auditLog("medication.delete", {
|
||||
userId,
|
||||
ipAddress: input.ipAddress,
|
||||
details: { medicationId: id, name: existing.name },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.delete",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
// Bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the deletion.
|
||||
invalidateUserMedications(userId, { evict: true });
|
||||
|
||||
// Its doses are gone with it, so the days they counted on are recomputed.
|
||||
if (hadComposition) await enqueueSupplementNutrientRecompute(userId);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* What a bulk intake batch accumulates while its entries are walked: the
|
||||
* per-entry results and counters the response carries, and the distinct
|
||||
* days, slots and medications the tails after the loop act on.
|
||||
*/
|
||||
import type { UnstableExternalIdShape } from "@/lib/validations/external-id";
|
||||
import type { InjectionSiteValue } from "@/lib/validations/medication";
|
||||
|
||||
/**
|
||||
* One entry of `POST /api/medications/intake/bulk`, as the route's schema
|
||||
* parses it.
|
||||
*/
|
||||
export interface BulkIntakeEntry {
|
||||
medicationId: string;
|
||||
scheduledFor?: Date;
|
||||
takenAt?: Date;
|
||||
skipped: boolean;
|
||||
idempotencyKey?: string;
|
||||
injectionSite?: InjectionSiteValue;
|
||||
forceSlotInstant?: Date;
|
||||
doseTaken?: string;
|
||||
source?: "APPLE_HEALTH";
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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";
|
||||
export interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface BatchState {
|
||||
results: EntryResult[];
|
||||
inserted: number;
|
||||
updated: number;
|
||||
duplicates: number;
|
||||
/**
|
||||
* External dose records kept as their own unscheduled dose because their
|
||||
* band slot already carried a different one.
|
||||
*/
|
||||
extraDosesUnscheduled: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
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.
|
||||
*/
|
||||
touchedDays: Map<string, { medicationId: string; dayKey: string }>;
|
||||
/**
|
||||
* Distinct `(medicationId, scheduledFor)` slots whose state actually
|
||||
* changed in this batch (#22: 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.
|
||||
*/
|
||||
syncSlots: 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.
|
||||
*/
|
||||
snoozeClearIds: Set<string>;
|
||||
/**
|
||||
* Live rows already carrying one of the batch's external dose-event UUIDs,
|
||||
* and the rows this batch landed under one, so an intra-batch repeat of a
|
||||
* UUID dedups too.
|
||||
*/
|
||||
rowIdByExternalId: Map<string, string>;
|
||||
}
|
||||
|
||||
export function createBatchState(
|
||||
rowIdByExternalId: Map<string, string>,
|
||||
): BatchState {
|
||||
return {
|
||||
results: [],
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
duplicates: 0,
|
||||
extraDosesUnscheduled: 0,
|
||||
skipped: [],
|
||||
unstableShapes: [],
|
||||
touchedDays: new Map(),
|
||||
syncSlots: new Map(),
|
||||
snoozeClearIds: new Set<string>(),
|
||||
rowIdByExternalId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Record an entry the batch skipped, with its reason. */
|
||||
export function skipEntry(state: BatchState, index: number, reason: string) {
|
||||
state.skipped.push({ index, reason });
|
||||
state.results.push({ index, status: "skipped", reason });
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Which slot one bulk intake entry binds to.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import {
|
||||
findPinConflict,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
slotHeldByAnotherDose,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import type { BulkIntakeEntry } from "./batch-state";
|
||||
|
||||
export interface EntryAttribution {
|
||||
canonicalSlot: Date | null;
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
heldByAnotherDose: boolean;
|
||||
}
|
||||
|
||||
interface EntryContext {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
entry: BulkIntakeEntry;
|
||||
incomingScheduledFor: Date;
|
||||
}
|
||||
|
||||
async function attributePin(
|
||||
ctx: EntryContext,
|
||||
forceSlotInstant: Date,
|
||||
): Promise<EntryAttribution | { reason: string }> {
|
||||
const { user, entry } = ctx;
|
||||
const canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
slotInstant: 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.
|
||||
return { reason: "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. 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,
|
||||
})
|
||||
) {
|
||||
return { reason: "force_slot_occupied" };
|
||||
}
|
||||
return {
|
||||
canonicalSlot,
|
||||
attributionSource: "USER_PIN",
|
||||
heldByAnotherDose: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function attributeTake(ctx: EntryContext): Promise<EntryAttribution> {
|
||||
const { user, entry } = ctx;
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
takenAt: entry.takenAt as Date,
|
||||
});
|
||||
let canonicalSlot = attribution.slotInstant;
|
||||
let heldByAnotherDose = false;
|
||||
// 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;
|
||||
}
|
||||
return { canonicalSlot, attributionSource: "AUTO", heldByAnotherDose };
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot of one entry, or the per-entry skip reason of a bad pin. The
|
||||
* write flags say what the entry is: an offline-sync replay echoes a PENDING
|
||||
* projection (no `takenAt`, `skipped:false`) for a slot the user may already
|
||||
* have actioned; an explicit `takenAt` or `skipped:true` is a real user
|
||||
* action and applies last-write-wins.
|
||||
*/
|
||||
export async function attributeEntry(
|
||||
ctx: EntryContext,
|
||||
flags: { isExplicitTaken: boolean; isExplicitSkip: boolean },
|
||||
): Promise<EntryAttribution | { reason: string }> {
|
||||
const { user, entry, incomingScheduledFor } = ctx;
|
||||
if (flags.isExplicitTaken && entry.forceSlotInstant !== undefined) {
|
||||
return attributePin(ctx, entry.forceSlotInstant);
|
||||
}
|
||||
if (flags.isExplicitTaken) {
|
||||
return attributeTake(ctx);
|
||||
}
|
||||
if (flags.isExplicitSkip && entry.scheduledFor === undefined) {
|
||||
// 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,
|
||||
});
|
||||
return {
|
||||
canonicalSlot: attribution.slotInstant,
|
||||
attributionSource: undefined,
|
||||
heldByAnotherDose: false,
|
||||
};
|
||||
}
|
||||
// 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.
|
||||
const 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,
|
||||
});
|
||||
return {
|
||||
canonicalSlot,
|
||||
attributionSource: undefined,
|
||||
heldByAnotherDose: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* The row one bulk intake entry lands on: the canonical slot write, the
|
||||
* idempotency replay, the convergence probe onto a row already on the named
|
||||
* instant, or the standalone insert, with the inventory hook each one owes.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
mayConvergeOntoSuppliedSlot,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import type { BatchState, BulkIntakeEntry } from "./batch-state";
|
||||
import type { EntryAttribution } from "./entry-attribution";
|
||||
|
||||
export interface EntryWrite {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
entry: BulkIntakeEntry;
|
||||
index: number;
|
||||
incomingScheduledFor: Date;
|
||||
isExplicitTaken: boolean;
|
||||
isExplicitSkip: boolean;
|
||||
attribution: EntryAttribution;
|
||||
resolvedInjectionSite: InjectionSiteKey | 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 misclassifying it
|
||||
* as a duplicate and dropping the dose.
|
||||
*/
|
||||
async function writeOntoSlot(
|
||||
write: EntryWrite,
|
||||
state: BatchState,
|
||||
canonicalSlot: Date,
|
||||
): Promise<void> {
|
||||
const { user, entry, index: i, isExplicitTaken, isExplicitSkip } = write;
|
||||
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,
|
||||
// An Apple dose-event import stamps its provenance on a
|
||||
// fresh row; every other caller keeps today's "API".
|
||||
createSource: entry.source ?? "API",
|
||||
injectionSite: write.resolvedInjectionSite,
|
||||
attributionSource: write.attribution.attributionSource,
|
||||
// Dose override only documents a consumed dose.
|
||||
doseTaken: (!entry.skipped && entry.doseTaken) || null,
|
||||
// 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.
|
||||
state.duplicates += 1;
|
||||
state.results.push({ index: i, status: "duplicate", id: applied.row.id });
|
||||
} else if (applied.outcome === "updated") {
|
||||
state.updated += 1;
|
||||
state.results.push({ index: i, status: "updated", id: applied.row.id });
|
||||
} else {
|
||||
state.inserted += 1;
|
||||
state.results.push({ index: i, status: "inserted", id: applied.row.id });
|
||||
}
|
||||
// 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 stamped 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 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"). 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).
|
||||
*/
|
||||
async function replayedRowId(write: EntryWrite): Promise<string | null> {
|
||||
const { user, entry } = write;
|
||||
if (!entry.idempotencyKey) return null;
|
||||
const replay = await prisma.medicationIntakeEvent.findUnique({
|
||||
where: {
|
||||
userId_idempotencyKey: {
|
||||
userId: user.id,
|
||||
idempotencyKey: entry.idempotencyKey,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return replay?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
async function insertStandalone(
|
||||
write: EntryWrite,
|
||||
state: BatchState,
|
||||
): Promise<Date> {
|
||||
const { user, entry, index: i, resolvedInjectionSite } = write;
|
||||
const effectiveScheduledFor = entry.takenAt ?? write.incomingScheduledFor;
|
||||
const row = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor: effectiveScheduledFor,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
// Apple provenance on a standalone (ad-hoc) row;
|
||||
// every other caller keeps today's "API".
|
||||
source: entry.source ?? "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
// External dose-event UUID for the idempotent
|
||||
// re-sync dedup.
|
||||
...(entry.externalId !== undefined && {
|
||||
externalId: entry.externalId,
|
||||
}),
|
||||
// Site only on a resolved taken-injection entry.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
// Dose override only on a taken entry carrying one.
|
||||
...(!entry.skipped && entry.doseTaken && { doseTaken: entry.doseTaken }),
|
||||
},
|
||||
});
|
||||
state.inserted += 1;
|
||||
state.results.push({ index: i, status: "inserted", id: row.id });
|
||||
// A fresh standalone taken row consumes inventory
|
||||
// units (a fresh skip / pending row has nothing to consume or
|
||||
// refund).
|
||||
if (write.isExplicitTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
eventId: row.id,
|
||||
intakeAt: entry.takenAt as Date,
|
||||
});
|
||||
}
|
||||
return effectiveScheduledFor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the entry. The answer is the `scheduledFor` instant the row actually
|
||||
* lands on, so the dose-day refresh keys the same day the stored row anchors
|
||||
* to, or null when an idempotency replay answered the entry and nothing
|
||||
* after the write applies to it.
|
||||
*/
|
||||
export async function writeEntry(
|
||||
write: EntryWrite,
|
||||
state: BatchState,
|
||||
): Promise<Date | null> {
|
||||
const { user, entry, index: i, incomingScheduledFor } = write;
|
||||
const { canonicalSlot, heldByAnotherDose } = write.attribution;
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row.
|
||||
await writeOntoSlot(write, state, canonicalSlot);
|
||||
return canonicalSlot;
|
||||
}
|
||||
// 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 (see `replayedRowId`);
|
||||
// 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.
|
||||
const replayId = await replayedRowId(write);
|
||||
if (replayId !== null) {
|
||||
state.duplicates += 1;
|
||||
state.results.push({ index: i, status: "duplicate", id: replayId });
|
||||
return null;
|
||||
}
|
||||
// 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) {
|
||||
await writeOntoSlot(write, state, incomingScheduledFor);
|
||||
return incomingScheduledFor;
|
||||
}
|
||||
return insertStandalone(write, state);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* One entry of a bulk intake batch, start to end: the per-entry refusals,
|
||||
* the slot attribution, the injection site, the write, and what the batch
|
||||
* remembers of a row that landed. One bad entry never fails the batch; every
|
||||
* refusal is that entry's own outcome.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import {
|
||||
INTAKE_NOT_TRACKED_REASON,
|
||||
isRecordOnly,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import {
|
||||
classifyExternalId,
|
||||
UNSTABLE_EXTERNAL_ID_REASON,
|
||||
} from "@/lib/validations/external-id";
|
||||
import type { InjectionSiteValue } from "@/lib/validations/medication";
|
||||
import {
|
||||
skipEntry,
|
||||
type BatchState,
|
||||
type BulkIntakeEntry,
|
||||
} from "./batch-state";
|
||||
import { attributeEntry } from "./entry-attribution";
|
||||
import { writeEntry } from "./entry-write";
|
||||
|
||||
/**
|
||||
* What the batch reads once about the medications it names: the
|
||||
* injection-tracking fields, so a per-entry `injectionSite` can be resolved +
|
||||
* validated without an extra per-entry read, and `externalSource`, so the
|
||||
* Apple-entry mirror gate needs no extra read either.
|
||||
*/
|
||||
export const OWNED_MEDICATION_SELECT = {
|
||||
id: true,
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
externalSource: true,
|
||||
trackIntake: true,
|
||||
} satisfies Prisma.MedicationSelect;
|
||||
|
||||
export type OwnedMedication = Prisma.MedicationGetPayload<{
|
||||
select: typeof OWNED_MEDICATION_SELECT;
|
||||
}>;
|
||||
|
||||
export interface BatchContext {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
ownedSet: Set<string>;
|
||||
medById: Map<string, OwnedMedication>;
|
||||
globalExcluded: InjectionSiteValue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusals decided before any slot is read. True when the entry is
|
||||
* answered (skipped, or a duplicate of a row the batch already knows).
|
||||
*/
|
||||
function screenEntry(
|
||||
ctx: BatchContext,
|
||||
state: BatchState,
|
||||
entry: BulkIntakeEntry,
|
||||
i: number,
|
||||
): boolean {
|
||||
if (!ctx.ownedSet.has(entry.medicationId)) {
|
||||
skipEntry(state, i, "medication_not_found");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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(ctx.medById.get(entry.medicationId) ?? {})) {
|
||||
skipEntry(state, i, INTAKE_NOT_TRACKED_REASON);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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. 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) {
|
||||
state.unstableShapes.push(unstable);
|
||||
skipEntry(state, i, UNSTABLE_EXTERNAL_ID_REASON);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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 = state.rowIdByExternalId.get(entry.externalId);
|
||||
if (existingRowId !== undefined) {
|
||||
state.duplicates += 1;
|
||||
state.results.push({ index: i, status: "duplicate", id: existingRowId });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function resolveEntrySite(
|
||||
ctx: BatchContext,
|
||||
entry: BulkIntakeEntry,
|
||||
isExplicitTaken: boolean,
|
||||
): { site: InjectionSiteKey | null } | { reason: string } {
|
||||
let resolvedInjectionSite: InjectionSiteKey | null = null;
|
||||
if (entry.injectionSite !== undefined) {
|
||||
const med = ctx.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: ctx.globalExcluded as InjectionSiteKey[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
return { reason: "injection_site_not_allowed" };
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
return { site: resolvedInjectionSite };
|
||||
}
|
||||
|
||||
/** What the batch remembers of a row that landed. */
|
||||
function recordLanded(
|
||||
ctx: BatchContext,
|
||||
state: BatchState,
|
||||
entry: BulkIntakeEntry,
|
||||
effectiveScheduledFor: Date,
|
||||
): void {
|
||||
const dayKey = dayKeyForScheduledFor(
|
||||
effectiveScheduledFor,
|
||||
ctx.user.timezone,
|
||||
);
|
||||
state.touchedDays.set(`${entry.medicationId}|${dayKey}`, {
|
||||
medicationId: entry.medicationId,
|
||||
dayKey,
|
||||
});
|
||||
// Collect the slot for the silent cross-device sync (#22)
|
||||
// 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.
|
||||
// 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 = state.results[state.results.length - 1];
|
||||
if (entry.externalId !== undefined && landed?.id !== undefined) {
|
||||
state.rowIdByExternalId.set(entry.externalId, landed.id);
|
||||
}
|
||||
const lastStatus = state.results[state.results.length - 1]?.status;
|
||||
if (lastStatus === "inserted" || lastStatus === "updated") {
|
||||
const scheduledForIso = effectiveScheduledFor.toISOString();
|
||||
state.syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, {
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor: scheduledForIso,
|
||||
});
|
||||
if (!entry.skipped) state.snoozeClearIds.add(entry.medicationId);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScreenedEntry(
|
||||
ctx: BatchContext,
|
||||
state: BatchState,
|
||||
entry: BulkIntakeEntry,
|
||||
i: number,
|
||||
): Promise<void> {
|
||||
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;
|
||||
|
||||
const attribution = await attributeEntry(
|
||||
{ user: ctx.user, entry, incomingScheduledFor },
|
||||
{ isExplicitTaken, isExplicitSkip },
|
||||
);
|
||||
if ("reason" in attribution) {
|
||||
skipEntry(state, i, attribution.reason);
|
||||
return;
|
||||
}
|
||||
if (attribution.heldByAnotherDose) state.extraDosesUnscheduled += 1;
|
||||
|
||||
const site = resolveEntrySite(ctx, entry, isExplicitTaken);
|
||||
if ("reason" in site) {
|
||||
skipEntry(state, i, site.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveScheduledFor = await writeEntry(
|
||||
{
|
||||
user: ctx.user,
|
||||
entry,
|
||||
index: i,
|
||||
incomingScheduledFor,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
attribution,
|
||||
resolvedInjectionSite: site.site,
|
||||
},
|
||||
state,
|
||||
);
|
||||
if (effectiveScheduledFor === null) return;
|
||||
recordLanded(ctx, state, entry, effectiveScheduledFor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function answerFailedEntry(
|
||||
ctx: BatchContext,
|
||||
state: BatchState,
|
||||
entry: BulkIntakeEntry,
|
||||
i: number,
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
const { user } = ctx;
|
||||
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 },
|
||||
})
|
||||
: // 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;
|
||||
state.duplicates += 1;
|
||||
state.results.push({
|
||||
index: i,
|
||||
status: "duplicate",
|
||||
id: existing?.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "create_failed";
|
||||
skipEntry(state, i, reason);
|
||||
}
|
||||
|
||||
/** Ingest one entry into the batch state. */
|
||||
export async function ingestEntry(
|
||||
ctx: BatchContext,
|
||||
state: BatchState,
|
||||
entry: BulkIntakeEntry,
|
||||
i: number,
|
||||
): Promise<void> {
|
||||
if (screenEntry(ctx, state, entry, i)) return;
|
||||
try {
|
||||
await writeScreenedEntry(ctx, state, entry, i);
|
||||
} catch (err: unknown) {
|
||||
await answerFailedEntry(ctx, state, entry, i, err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Ingest one bulk intake batch: `POST /api/medications/intake/bulk`.
|
||||
*
|
||||
* The route authenticates, meters its own bulk bucket, reads the body and
|
||||
* answers the size and schema refusals; the ownership read, the Apple mirror
|
||||
* gate, the batch pre-reads, the per-entry loop and every tail after it run
|
||||
* here, in the order they always ran. The outcome is the batch refusal the
|
||||
* route answers with `apiError`, or the always-200 batch envelope.
|
||||
*/
|
||||
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 { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import {
|
||||
BACKFILL_SYNC_TRIGGER,
|
||||
MEDICATION_DOSE_ARRIVAL_TYPE,
|
||||
recordHealthKitTypeArrivals,
|
||||
type HealthKitRequestSyncTrigger,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { refreshDoseDay } from "@/lib/medications/dose-day-refresh";
|
||||
import { unstableExternalIdMeta } from "@/lib/validations/external-id";
|
||||
import type { InjectionSiteValue } from "@/lib/validations/medication";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import {
|
||||
createBatchState,
|
||||
type BatchState,
|
||||
type BulkIntakeEntry,
|
||||
} from "./batch-state";
|
||||
import {
|
||||
ingestEntry,
|
||||
OWNED_MEDICATION_SELECT,
|
||||
type BatchContext,
|
||||
} from "./entry";
|
||||
|
||||
interface BatchInput {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
/**
|
||||
* A narrow, single-purpose credential (a bridge, an ingest token) is no
|
||||
* Apple Health sync, so it never writes the dose arrival record.
|
||||
*/
|
||||
scoped: boolean;
|
||||
entries: BulkIntakeEntry[];
|
||||
syncTrigger: HealthKitRequestSyncTrigger | undefined;
|
||||
ipAddress: string | null;
|
||||
originDeviceToken: string | null;
|
||||
}
|
||||
|
||||
export type BulkIntakeOutcome =
|
||||
| { kind: "refused"; message: string; errorCode: string }
|
||||
| { kind: "ingested"; body: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function loadKnownExternalIds(
|
||||
userId: string,
|
||||
entries: BulkIntakeEntry[],
|
||||
): Promise<Map<string, string>> {
|
||||
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,
|
||||
externalId: { in: batchExternalIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true, externalId: true },
|
||||
});
|
||||
for (const row of existingExternalRows) {
|
||||
if (row.externalId) rowIdByExternalId.set(row.externalId, row.id);
|
||||
}
|
||||
}
|
||||
return rowIdByExternalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function loadGlobalExcluded(
|
||||
userId: string,
|
||||
entries: BulkIntakeEntry[],
|
||||
): Promise<InjectionSiteValue[]> {
|
||||
const batchHasInjectionSite = entries.some(
|
||||
(e) => e.injectionSite !== undefined,
|
||||
);
|
||||
let globalExcluded: InjectionSiteValue[] = [];
|
||||
if (batchHasInjectionSite) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
globalExcluded = (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[];
|
||||
}
|
||||
return globalExcluded;
|
||||
}
|
||||
|
||||
/** The audit row and the event of the batch. */
|
||||
async function recordBatch(input: BatchInput, state: BatchState) {
|
||||
const { entries } = input;
|
||||
const { inserted, updated, duplicates, skipped, unstableShapes } = state;
|
||||
await auditLog("medications.intake.bulk.ingest", {
|
||||
userId: input.user.id,
|
||||
ipAddress: input.ipAddress,
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.bulk.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
extra_doses_unscheduled: state.extraDosesUnscheduled,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The caches, the dose days, the snooze, and the cross-device and web-push
|
||||
* tails of the rows the batch landed.
|
||||
*/
|
||||
async function settleLandedRows(input: BatchInput, state: BatchState) {
|
||||
const { user } = input;
|
||||
// Bust per-user medications + compliance + achievement caches when at
|
||||
// least one row landed so the next read reflects the ingested batch.
|
||||
// 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 (state.inserted > 0 || state.updated > 0) {
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
// 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 state.touchedDays.values()) {
|
||||
await refreshDoseDay({
|
||||
userId: user.id,
|
||||
medicationId,
|
||||
dayKey,
|
||||
tz: user.timezone,
|
||||
});
|
||||
}
|
||||
|
||||
// Silent cross-device intake sync (#22). 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 (state.snoozeClearIds.size > 0) {
|
||||
await prisma.medication.updateMany({
|
||||
where: { id: { in: [...state.snoozeClearIds] }, userId: user.id },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (state.syncSlots.size > 0) {
|
||||
queueMedicationIntakeSync({
|
||||
userId: user.id,
|
||||
originDeviceToken: input.originDeviceToken,
|
||||
});
|
||||
|
||||
// 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(state.syncSlots.values());
|
||||
void (async () => {
|
||||
const badgeCount = await countOutstandingDosesToday(
|
||||
user.id,
|
||||
user.timezone,
|
||||
);
|
||||
await dispatchMedicationIntakeWebClearBulk({
|
||||
userId: user.id,
|
||||
slots: affectedSlots,
|
||||
badgeCount,
|
||||
});
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Then the
|
||||
* SyncMode checkpoint, which 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.
|
||||
*/
|
||||
async function recordSyncArrival(input: BatchInput, state: BatchState) {
|
||||
const { user, entries, syncTrigger } = input;
|
||||
const doseArrival = { accepted: 0, newSamples: 0 };
|
||||
for (const result of state.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 && !input.scoped) {
|
||||
await recordHealthKitTypeArrivals(prisma, {
|
||||
userId: user.id,
|
||||
counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),
|
||||
trigger: syncTrigger ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (syncTrigger !== BACKFILL_SYNC_TRIGGER) {
|
||||
await markSyncCheckpoint(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ingestBulkIntake(
|
||||
input: BatchInput,
|
||||
): Promise<BulkIntakeOutcome> {
|
||||
const { user, entries } = input;
|
||||
|
||||
// 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 },
|
||||
select: OWNED_MEDICATION_SELECT,
|
||||
});
|
||||
const ownedSet = new Set(ownedMedications.map((m) => m.id));
|
||||
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
|
||||
|
||||
// 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.
|
||||
const appleMismatch = entries.some(
|
||||
(e) =>
|
||||
e.source === "APPLE_HEALTH" &&
|
||||
ownedSet.has(e.medicationId) &&
|
||||
medById.get(e.medicationId)?.externalSource !== "APPLE_HEALTH",
|
||||
);
|
||||
if (appleMismatch) {
|
||||
return {
|
||||
kind: "refused",
|
||||
message:
|
||||
"An APPLE_HEALTH entry may only target a medication mirrored from Apple Health",
|
||||
errorCode: "medications.intake.bulk.apple_health_not_mirrored",
|
||||
};
|
||||
}
|
||||
|
||||
const state = createBatchState(await loadKnownExternalIds(user.id, entries));
|
||||
const ctx: BatchContext = {
|
||||
user,
|
||||
ownedSet,
|
||||
medById,
|
||||
globalExcluded: await loadGlobalExcluded(user.id, entries),
|
||||
};
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
await ingestEntry(ctx, state, entries[i], i);
|
||||
}
|
||||
|
||||
await recordBatch(input, state);
|
||||
await settleLandedRows(input, state);
|
||||
await recordSyncArrival(input, state);
|
||||
|
||||
return {
|
||||
kind: "ingested",
|
||||
body: {
|
||||
processed: entries.length,
|
||||
inserted: state.inserted,
|
||||
updated: state.updated,
|
||||
duplicates: state.duplicates,
|
||||
skipped: state.skipped,
|
||||
entries: state.results,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Per-day scheduled vs taken for the last N days:
|
||||
* `GET /api/medications/intake?scope=compliance`.
|
||||
*
|
||||
* Cached at a 15-minute TTL because daily compliance buckets are slow-moving
|
||||
* (intake events trickle in, yesterday's row doesn't move). The cache key
|
||||
* carries the userTz so a user who changes timezone doesn't read another
|
||||
* tz's bucketing.
|
||||
*
|
||||
* `scheduled` is the SCHEDULE-ANCHORED expected-dose count per day (the
|
||||
* canonical recurrence engine), NOT the count of logged intake rows. The old
|
||||
* rollup-backed path set `scheduled = COUNT(*)` of intake rows, so the
|
||||
* dashboard tile's rate (`taken / scheduled`) was ~100% across every window
|
||||
* regardless of real adherence — every logged row was both numerator and
|
||||
* denominator. Anchoring `scheduled` to the schedule makes the rate genuinely
|
||||
* reflect taken-of-expected and lets the 7/30/90 windows diverge with partial
|
||||
* adherence.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
buildScheduleAnchoredComplianceBuckets,
|
||||
type ScheduleAnchoredComplianceBucket,
|
||||
} from "@/lib/analytics/schedule-anchored-compliance";
|
||||
|
||||
export async function readIntakeCompliance(input: {
|
||||
userId: string;
|
||||
days: number;
|
||||
userTz: string;
|
||||
}): Promise<ScheduleAnchoredComplianceBucket[]> {
|
||||
const { userId, days, userTz } = input;
|
||||
const result = await cached(
|
||||
caches.medicationsIntake as ServerCache<ScheduleAnchoredComplianceBucket[]>,
|
||||
`${userId}|compliance|${days}|${userTz}`,
|
||||
() => buildScheduleAnchoredComplianceBuckets(userId, days, userTz),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.compliance" },
|
||||
meta: { days, count: result.length },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Today's intake events across the record's medications:
|
||||
* `GET /api/medications/intake?scope=today`.
|
||||
*
|
||||
* The route authenticates (READ over the `medications` section) and parses
|
||||
* the query; the projection, the read and the status mapping run here.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export async function readTodayIntakes(input: {
|
||||
userId: string;
|
||||
userTz: string;
|
||||
}) {
|
||||
const { userId, userTz } = input;
|
||||
const todayStart = startOfLocalDayInTz(new Date(), userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// Project pending intake rows for every active schedule whose window opens
|
||||
// today, then idempotently backfill any missing rows. Without it the
|
||||
// endpoint returned `[]` for daily meds (`schedule.daysOfWeek = null` in the
|
||||
// DB) until the reminder worker entered the RED phase at the end of the
|
||||
// dose window — leaving the iOS Dashboard tile + the "Erfassen" sheet empty
|
||||
// for the whole morning. The shared helper mirrors the dashboard-summary
|
||||
// call site so the two routes converge on the same row set.
|
||||
const { projected, backfilled } = await projectTodayIntakesAndRecompute({
|
||||
userId,
|
||||
userTz,
|
||||
todayStart,
|
||||
todayEnd,
|
||||
});
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// Exclude tombstoned rows from the today list.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
// The today list is what clients offer doses from (#1033); a
|
||||
// medication with intake tracking off is not on it.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
include: { medication: { select: { id: true, snoozedUntil: true } } },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.today" },
|
||||
meta: {
|
||||
count: events.length,
|
||||
projected,
|
||||
backfilled,
|
||||
},
|
||||
});
|
||||
|
||||
return events.map((e) => ({
|
||||
id: e.id,
|
||||
medicationId: e.medicationId,
|
||||
scheduledAt: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
status: e.skipped
|
||||
? "skipped"
|
||||
: e.takenAt
|
||||
? "taken"
|
||||
: // A never-acted dose the auto-miss cron flipped is a
|
||||
// terminal MISS, not a perpetual "pending". (Today's freshly
|
||||
// projected rows are < 24 h old so this rarely fires here, but
|
||||
// a stale row surfacing in the window reads honestly.)
|
||||
e.autoMissed
|
||||
? "missed"
|
||||
: e.medication.snoozedUntil && e.medication.snoozedUntil > new Date()
|
||||
? "snoozed"
|
||||
: "pending",
|
||||
snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,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,277 @@
|
||||
/**
|
||||
* Read one medication: `GET /api/medications/{id}`.
|
||||
*
|
||||
* The route authenticates (READ over the `medications` section) and answers
|
||||
* the 404; the fetch, the category enrichment, the server-computed next due
|
||||
* instant, the stock and runway, the composition, the courses and the related
|
||||
* records are resolved here, in the order they always were. `null` means the
|
||||
* medication is not the record's.
|
||||
*/
|
||||
import type { Medication, MedicationSchedule } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
|
||||
import { toIngredientDtos } from "@/lib/medications/ingredients/composition";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { resolveMedicationCategories } from "@/lib/medication-category";
|
||||
import { resolveCourseFields } from "@/lib/medications/courses";
|
||||
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
|
||||
import {
|
||||
computeDisplayDue,
|
||||
OVERDUE_LOOKBACK_MS,
|
||||
toResolvedSlotMark,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import {
|
||||
effectiveUnitsPerDose,
|
||||
estimateUnitsRunwayDays,
|
||||
} from "@/lib/medications/supply-runway";
|
||||
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
|
||||
import {
|
||||
dueSchedules,
|
||||
isRecordOnly,
|
||||
scheduleWireFields,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { floorWhole } from "@/lib/medications/units-per-dose";
|
||||
import { resolveIntakeActionability } from "@/lib/medications/intake-actionable";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { buildRelated } from "@/lib/links/related";
|
||||
|
||||
type ReadUser = Pick<SessionUser, "id" | "timezone" | "locale">;
|
||||
type MedicationWithSchedules = Medication & {
|
||||
schedules: MedicationSchedule[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The server-computed next due instant. Rolling cadences re-anchor on the
|
||||
* latest non-skipped intake, so fetch it once (no-op for calendar cadences
|
||||
* but cheap + keeps the value correct for rolling injections).
|
||||
*/
|
||||
async function loadDisplayDue(
|
||||
user: ReadUser,
|
||||
id: string,
|
||||
medication: MedicationWithSchedules,
|
||||
) {
|
||||
const lastIntake = dueSchedules(medication).some(
|
||||
(s) => s.rollingIntervalDays !== null,
|
||||
)
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
// A tombstoned intake no longer anchors the rolling-interval
|
||||
// next-due computation.
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
deletedAt: null,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
select: { takenAt: true },
|
||||
})
|
||||
: null;
|
||||
// Slots the user has already acted on near now, so the next-due search
|
||||
// skips them and advances to the next genuinely-open slot. Bound the read
|
||||
// to [now-1d, now+2d] — the lookahead only needs the slots adjacent to now.
|
||||
const now = new Date();
|
||||
// Reach back as far as the widest band tail so a long-resolved past slot
|
||||
// can never resurface as "overdue".
|
||||
const resolvedFrom = new Date(now.getTime() - OVERDUE_LOOKBACK_MS);
|
||||
const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
const resolvedRows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: resolvedFrom, lte: resolvedTo },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }, { autoMissed: true }],
|
||||
},
|
||||
// `takenAt` rides along so an ad-hoc row (`scheduledFor === takenAt`)
|
||||
// cannot ±6h-resolve a different slot.
|
||||
select: { scheduledFor: true, takenAt: true },
|
||||
});
|
||||
// Current-era floor: the open-overdue search mints from the LIVE schedule
|
||||
// rows, so it must not reach past the newest revision boundary into a
|
||||
// previous era's cadence.
|
||||
const latestRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
// Superseded rows are audit records — a correction may have
|
||||
// shortened the era, so the boundary reads only active rows.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
// Intake tracking off (#1033): nothing is due from the stored rows, and no
|
||||
// runway derives from them.
|
||||
const liveSchedules = dueSchedules(medication);
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
// Under auto-take a rolling cadence stands where the job will record next
|
||||
// (see `auto-take.ts`).
|
||||
const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, {
|
||||
userId: user.id,
|
||||
medicationIds: medication.autoMarkTaken ? [id] : [],
|
||||
now,
|
||||
userTz,
|
||||
lastIntakeAt: () => lastIntake?.takenAt ?? null,
|
||||
});
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
},
|
||||
schedules: liveSchedules,
|
||||
now,
|
||||
userTz,
|
||||
lastIntakeAt: engineLastIntakeAt(id),
|
||||
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
|
||||
eraStart: latestRevision?.validUntil ?? null,
|
||||
});
|
||||
return { display, userTz, now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock + runway on the detail wire, mirroring the list payload's semantics:
|
||||
* NULL stock = inventory tracking off, 0 = the supply ran out; runwayDays via
|
||||
* the slot-aware burn rate. Published resolved so no client re-derives the
|
||||
* inheritance / cadence math.
|
||||
*/
|
||||
async function loadStock(
|
||||
user: ReadUser,
|
||||
id: string,
|
||||
medication: MedicationWithSchedules,
|
||||
) {
|
||||
const [usableStock, anyItemCount] = await Promise.all([
|
||||
prisma.medicationInventoryItem.aggregate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
state: { in: ["ACTIVE", "IN_USE"] },
|
||||
unitsRemaining: { gt: 0 },
|
||||
},
|
||||
_sum: { unitsRemaining: true },
|
||||
}),
|
||||
prisma.medicationInventoryItem.count({
|
||||
where: { userId: user.id, medicationId: id },
|
||||
}),
|
||||
]);
|
||||
const schedulesDto = serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
);
|
||||
const stockUnitsRemaining =
|
||||
anyItemCount > 0 ? Number(usableStock._sum.unitsRemaining ?? 0) : null;
|
||||
const stockDosesRemaining =
|
||||
stockUnitsRemaining === null
|
||||
? null
|
||||
: floorWhole(
|
||||
stockUnitsRemaining /
|
||||
effectiveUnitsPerDose(
|
||||
schedulesDto,
|
||||
Number(medication.unitsPerDose),
|
||||
),
|
||||
);
|
||||
const runwayDays =
|
||||
stockUnitsRemaining === null
|
||||
? null
|
||||
: estimateUnitsRunwayDays(
|
||||
stockUnitsRemaining,
|
||||
isRecordOnly(medication) ? [] : schedulesDto,
|
||||
Number(medication.unitsPerDose),
|
||||
);
|
||||
return { schedulesDto, stockUnitsRemaining, stockDosesRemaining, runwayDays };
|
||||
}
|
||||
|
||||
/** The detail answer of one medication, or null for the route's 404. */
|
||||
export async function readMedication(input: {
|
||||
user: ReadUser;
|
||||
grantId: string | null;
|
||||
id: string;
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
const { user, grantId, id } = input;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
});
|
||||
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let category = "OTHER";
|
||||
let categoryLabel: string | null = null;
|
||||
try {
|
||||
const categories = await resolveMedicationCategories([id]);
|
||||
category = categories[id]?.category ?? "OTHER";
|
||||
categoryLabel = categories[id]?.categoryLabel ?? null;
|
||||
} catch {
|
||||
// Category enrichment is optional
|
||||
}
|
||||
|
||||
const { display, userTz, now } = await loadDisplayDue(user, id, medication);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.get",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const { schedulesDto, stockUnitsRemaining, stockDosesRemaining, runwayDays } =
|
||||
await loadStock(user, id, medication);
|
||||
|
||||
// The composition per unit, labelled in the account language.
|
||||
const ingredientRows = await prisma.medicationIngredient.findMany({
|
||||
where: { medicationId: id, userId: user.id },
|
||||
select: {
|
||||
nutrient: true,
|
||||
amountPerUnit: true,
|
||||
unit: true,
|
||||
enteredAmount: true,
|
||||
enteredUnit: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The courses, resolved on the server (#1024).
|
||||
const courseFields = (
|
||||
await resolveCourseFields(
|
||||
[medication],
|
||||
now,
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
)
|
||||
).get(id);
|
||||
|
||||
return {
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// `schedules: []` plus `recordedSchedules` when intake tracking is off
|
||||
// (#1033, see `scheduleWireFields`).
|
||||
...scheduleWireFields(medication.trackIntake, schedulesDto),
|
||||
category,
|
||||
categoryLabel,
|
||||
...courseFields,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
// The server's own reminder decision, published resolved.
|
||||
remindersActive: sendsMedicationReminders(medication, {
|
||||
now,
|
||||
timeZone: userTz,
|
||||
}),
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
// See `resolveIntakeActionability` (#1040).
|
||||
...resolveIntakeActionability(
|
||||
medication,
|
||||
now,
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
),
|
||||
stockUnitsRemaining,
|
||||
stockDosesRemaining,
|
||||
runwayDays,
|
||||
ingredients: toIngredientDtos(ingredientRows, user.locale ?? null),
|
||||
// The records this medication relates to (`src/lib/links/`).
|
||||
related: await buildRelated(prisma, {
|
||||
userId: user.id,
|
||||
grantId,
|
||||
kind: "medication",
|
||||
id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Whether a status update to `POST /api/medications/intake` may touch the
|
||||
* event it names: the live event under the record, the record-only rule, the
|
||||
* delegated refusals, and the injection site it carries.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
type InjectionSiteValue,
|
||||
type intakeStatusUpdateSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export type IntakeStatusUpdate = z.output<typeof intakeStatusUpdateSchema>;
|
||||
|
||||
/** A refusal the route answers with `apiError(message, status, meta)`. */
|
||||
export interface IntakeStatusRefusal {
|
||||
kind: "refused";
|
||||
message: string;
|
||||
status: number;
|
||||
errorCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live event a status update may change, with what the site check needs
|
||||
* of its medication, or null for the route's 404. A tombstoned intake 404s on
|
||||
* a status toggle; the `deletedAt: null` filter refuses to mutate a
|
||||
* soft-deleted row.
|
||||
*/
|
||||
export async function findIntakeForStatusUpdate(input: {
|
||||
userId: string;
|
||||
intakeId: string;
|
||||
}) {
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: { id: input.intakeId, deletedAt: null },
|
||||
include: {
|
||||
medication: {
|
||||
select: {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
trackIntake: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== input.userId) {
|
||||
return null;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
export type StatusUpdateEvent = NonNullable<
|
||||
Awaited<ReturnType<typeof findIntakeForStatusUpdate>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* A record-only medication keeps the doses it already has, and an owner may
|
||||
* still correct one of those (that is an edit of history). What it no longer
|
||||
* takes is a NEW dose: resolving or snoozing an event that is still open
|
||||
* would record one, and it would then be counted nowhere (iOS #116, item
|
||||
* 15). Refused by name, the same code the per-medication route answers with.
|
||||
*/
|
||||
export function refusesUntrackedIntake(existing: StatusUpdateEvent): boolean {
|
||||
const stillOpen = existing.takenAt === null && !existing.skipped;
|
||||
return stillOpen && isRecordOnly(existing.medication);
|
||||
}
|
||||
|
||||
/**
|
||||
* This route is admitted for delegates because marking a dose is the thing a
|
||||
* person standing next to the patient needs to do. It is an UPDATE, though,
|
||||
* and two of the transitions it can express are not that.
|
||||
*
|
||||
* Snoozing writes `snoozedUntil` on the OWNER's medication row, and the
|
||||
* reminder cron skips a medication for as long as that stamp is in the
|
||||
* future. The field is unbounded, the owner is deliberately not notified of
|
||||
* a delegated snooze (there is nothing useful to say about a thirty-minute
|
||||
* deferral), and the activity feed renders the whole family as "marked a
|
||||
* dose". So a delegate could switch the owner's medication reminders off for
|
||||
* years, and every surface that exists to make delegation visible would
|
||||
* describe it as marking a dose. In an adherence product that is the worst
|
||||
* thing on this route.
|
||||
*
|
||||
* Flipping an already-resolved event is the other one: taken to skipped and
|
||||
* back rewrites the owner's compliance history and refunds inventory. The
|
||||
* shipped promise, on the consent screen and in the release notes, is that
|
||||
* editing what is already there stays with the owner. Marking a dose that is
|
||||
* still open is a contribution; changing a decision the owner already
|
||||
* recorded is an edit.
|
||||
*
|
||||
* Both refuse for a delegate and stay open to the owner, who is the only
|
||||
* person the snooze and the correction were ever for.
|
||||
*/
|
||||
export async function refuseDelegatedChange(input: {
|
||||
userId: string;
|
||||
actorId: string;
|
||||
existing: StatusUpdateEvent;
|
||||
intakeId: string;
|
||||
status: IntakeStatusUpdate["status"];
|
||||
ipAddress: string | null;
|
||||
}): Promise<IntakeStatusRefusal | null> {
|
||||
const { userId, existing, intakeId, status } = input;
|
||||
if (input.actorId !== userId) {
|
||||
const alreadyResolved = existing.takenAt !== null || existing.skipped;
|
||||
const refusal =
|
||||
status === "snoozed"
|
||||
? "snooze"
|
||||
: alreadyResolved
|
||||
? "resolved_event"
|
||||
: null;
|
||||
if (refusal) {
|
||||
annotate({
|
||||
action: { name: "medications.intake.update.refused" },
|
||||
meta: { reason: refusal },
|
||||
});
|
||||
// Filed under the OWNER. `auditLog` stamps the actor itself from the
|
||||
// request's acting context, which is what puts this on the trail the
|
||||
// owner's activity panel reads — a refused attempt to change their
|
||||
// record is worth as much to them as a successful one.
|
||||
await auditLog("medications.intake.update.refused", {
|
||||
userId,
|
||||
ipAddress: input.ipAddress,
|
||||
details: { intakeId, status, reason: refusal },
|
||||
});
|
||||
return {
|
||||
kind: "refused",
|
||||
message:
|
||||
refusal === "snooze"
|
||||
? "Snoozing a reminder is not part of shared access"
|
||||
: "Changing a dose that is already recorded is not part of shared access",
|
||||
status: 403,
|
||||
errorCode: "sharing.not_permitted",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve + server-validate the optional injection site for a "taken"
|
||||
* toggle. A site outside the medication's effective allowed set is a hard
|
||||
* 422; non-injection / tracking-off / non-taken drops it.
|
||||
*/
|
||||
export async function resolveStatusInjectionSite(input: {
|
||||
userId: string;
|
||||
existing: StatusUpdateEvent;
|
||||
injectionSite: IntakeStatusUpdate["injectionSite"];
|
||||
status: IntakeStatusUpdate["status"];
|
||||
}): Promise<
|
||||
{ kind: "site"; site: InjectionSiteValue | null } | IntakeStatusRefusal
|
||||
> {
|
||||
const { existing, injectionSite, status } = input;
|
||||
let resolvedInjectionSite: InjectionSiteValue | null = null;
|
||||
if (injectionSite !== undefined) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: injectionSite,
|
||||
taken: status === "taken",
|
||||
deliveryForm: existing.medication.deliveryForm,
|
||||
trackInjectionSites: existing.medication.trackInjectionSites,
|
||||
allowedInjectionSites: existing.medication
|
||||
.allowedInjectionSites as InjectionSiteValue[],
|
||||
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
annotate({
|
||||
action: { name: "medication.intake.injection_site.disallowed" },
|
||||
meta: { medication_id: existing.medicationId, site: resolution.site },
|
||||
});
|
||||
return {
|
||||
kind: "refused",
|
||||
message: "Injection site is not allowed for this medication",
|
||||
status: 422,
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
};
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
return { kind: "site", site: resolvedInjectionSite };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* A "taken" status update: re-run window-band attribution so an edited /
|
||||
* off-window take re-binds to the right slot instead of leaving
|
||||
* `scheduledFor` stale (audit HIGH-4). `forceSlotInstant` pins onto a named
|
||||
* real slot (422 if it is not one); otherwise band membership picks the slot
|
||||
* (the take's own time on a miss → ad-hoc). Mirrors the per-event PUT route.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import type { InjectionSiteValue } from "@/lib/validations/medication";
|
||||
import type { IntakeStatusRefusal, StatusUpdateEvent } from "./admission";
|
||||
|
||||
interface TakeInput {
|
||||
userId: string;
|
||||
intakeId: string;
|
||||
existing: StatusUpdateEvent;
|
||||
takenAt: Date | undefined;
|
||||
forceSlotInstant: Date | undefined;
|
||||
resolvedInjectionSite: InjectionSiteValue | null;
|
||||
userTz: string;
|
||||
}
|
||||
|
||||
async function targetSlotOf(
|
||||
input: TakeInput,
|
||||
resolvedTakenAt: Date,
|
||||
): Promise<Date | IntakeStatusRefusal> {
|
||||
const { userId, existing, intakeId, forceSlotInstant, userTz } = input;
|
||||
if (forceSlotInstant !== undefined) {
|
||||
const forced = await resolveForcedSlotForWrite({
|
||||
userId,
|
||||
medicationId: existing.medicationId,
|
||||
userTz,
|
||||
slotInstant: forceSlotInstant,
|
||||
});
|
||||
if (forced === null) {
|
||||
annotate({
|
||||
action: { name: "medication.intake.force_slot.invalid" },
|
||||
meta: { medication_id: existing.medicationId, intake_id: intakeId },
|
||||
});
|
||||
return {
|
||||
kind: "refused",
|
||||
message: "forceSlotInstant is not a scheduled slot of this medication",
|
||||
status: 422,
|
||||
errorCode: "medications.intake.force_slot.invalid",
|
||||
};
|
||||
}
|
||||
return forced;
|
||||
}
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId,
|
||||
medicationId: existing.medicationId,
|
||||
userTz,
|
||||
takenAt: resolvedTakenAt,
|
||||
});
|
||||
return attribution.slotInstant ?? resolvedTakenAt;
|
||||
}
|
||||
|
||||
async function takeInPlace(
|
||||
input: TakeInput,
|
||||
resolvedTakenAt: Date,
|
||||
wasTaken: boolean,
|
||||
) {
|
||||
const { userId, intakeId, existing, resolvedInjectionSite } = input;
|
||||
const [updated] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
// Bump the reconciliation counter on every
|
||||
// server-side mutation so the delta feed echoes a monotonic value.
|
||||
data: {
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped: false,
|
||||
// The person confirmed it; no longer automatic.
|
||||
autoRecorded: false,
|
||||
syncVersion: { increment: 1 },
|
||||
// Persist the resolved site on the taken branch.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]);
|
||||
// The toggle recorded a take; consume inventory units
|
||||
// on the genuine pending→taken (or skipped→taken) transition. The
|
||||
// stamp additionally keeps a racing replay exactly-once.
|
||||
if (!wasTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId,
|
||||
medicationId: existing.medicationId,
|
||||
eventId: intakeId,
|
||||
intakeAt: resolvedTakenAt,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The take re-attributed to a different slot. Tombstone the source row and
|
||||
* route the dose through the shared canonical-slot upsert, which converges
|
||||
* onto any row already at the target slot rather than bare-updating into an
|
||||
* occupied slot (P2002-safe).
|
||||
*
|
||||
* Refund the source row's consumption stamp BEFORE the tombstone, then
|
||||
* consume on the converged row below: a slot move is a re-binding of one
|
||||
* real dose, so the stock nets exactly one consumption.
|
||||
*/
|
||||
async function moveTake(
|
||||
input: TakeInput,
|
||||
resolvedTakenAt: Date,
|
||||
targetScheduledFor: Date,
|
||||
wasTaken: boolean,
|
||||
) {
|
||||
const { userId, intakeId, existing, resolvedInjectionSite } = input;
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId,
|
||||
eventId: intakeId,
|
||||
});
|
||||
await prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId,
|
||||
medicationId: existing.medicationId,
|
||||
canonicalSlot: targetScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
// Carry the recorded site / dose override across the
|
||||
// tombstone + re-insert: a slot move is a re-binding, not a new
|
||||
// dose, so the original row's documentation must survive when the
|
||||
// toggle write itself carries none.
|
||||
injectionSite:
|
||||
resolvedInjectionSite ??
|
||||
(existing.injectionSite as InjectionSiteValue | null),
|
||||
doseTaken: existing.doseTaken,
|
||||
});
|
||||
await prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
// Consume on the converged row (the old row's stamp
|
||||
// was refunded above, so the move nets one consumption). A taken
|
||||
// source WITHOUT a stamp is a pre-v1.16.10 row whose stock moved
|
||||
// through the legacy hook: the refund above was a no-op, so
|
||||
// consuming on the target would double-charge — skip it.
|
||||
if (!wasTaken || existing.inventoryConsumption !== null) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId,
|
||||
medicationId: existing.medicationId,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: resolvedTakenAt,
|
||||
});
|
||||
}
|
||||
return applied.row;
|
||||
}
|
||||
|
||||
/** Record the take, in place or on the slot it now binds to. */
|
||||
export async function applyTakenStatus(input: TakeInput) {
|
||||
const resolvedTakenAt = input.takenAt ?? new Date();
|
||||
|
||||
const target = await targetSlotOf(input, resolvedTakenAt);
|
||||
if (!(target instanceof Date)) return target;
|
||||
const targetScheduledFor = target;
|
||||
|
||||
const slotMoved =
|
||||
targetScheduledFor.getTime() !== input.existing.scheduledFor.getTime();
|
||||
|
||||
// Transition gate for the inventory consume. Only
|
||||
// a row moving INTO taken may consume; a re-take of an already-
|
||||
// taken row (e.g. a time correction) leaves its stamp frozen, and a
|
||||
// pre-v1.16.10 taken row (NULL stamp, stock already moved by the
|
||||
// legacy hook at take time) must never retro-consume.
|
||||
const wasTaken = input.existing.takenAt !== null && !input.existing.skipped;
|
||||
const updated = !slotMoved
|
||||
? await takeInPlace(input, resolvedTakenAt, wasTaken)
|
||||
: await moveTake(input, resolvedTakenAt, targetScheduledFor, wasTaken);
|
||||
return { kind: "updated" as const, row: updated };
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Mark, skip or snooze one intake event: `POST /api/medications/intake`.
|
||||
*
|
||||
* The route authenticates (WRITE over the `medications` section), parses the
|
||||
* body and answers the schema refusal and the 404; the record-only rule, the
|
||||
* delegated refusals, the injection site, the status write and every tail
|
||||
* after it run here, in the order they always ran. Everything downstream
|
||||
* addresses the RECORD: the cross-device intake sync wakes the OWNER's iOS
|
||||
* devices, the web-clear closes the OWNER's pending dose reminder and
|
||||
* rewrites the OWNER's badge, and the compliance rollup recomputes the
|
||||
* OWNER's day. The owner is told who marked the dose at the end.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import {
|
||||
refuseDelegatedChange,
|
||||
refusesUntrackedIntake,
|
||||
resolveStatusInjectionSite,
|
||||
type IntakeStatusRefusal,
|
||||
type IntakeStatusUpdate,
|
||||
type StatusUpdateEvent,
|
||||
} from "./admission";
|
||||
import { applyTakenStatus } from "./take";
|
||||
|
||||
interface StatusContext {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
actorId: string;
|
||||
existing: StatusUpdateEvent;
|
||||
ipAddress: string | null;
|
||||
originDeviceToken: string | null;
|
||||
}
|
||||
|
||||
type UpdatedRow = Extract<
|
||||
Awaited<ReturnType<typeof applyTakenStatus>>,
|
||||
{ kind: "updated" }
|
||||
>["row"];
|
||||
|
||||
export type IntakeStatusOutcome =
|
||||
| IntakeStatusRefusal
|
||||
| { kind: "untracked" }
|
||||
| { kind: "updated"; row: UpdatedRow | null };
|
||||
|
||||
/** The skip and the snooze; the take has its own module. */
|
||||
async function applySkipOrSnooze(ctx: StatusContext, data: IntakeStatusUpdate) {
|
||||
const { user, existing } = ctx;
|
||||
const { intakeId, status, snoozedUntil } = data;
|
||||
if (status === "skipped") {
|
||||
const updated = await prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
// Bump the reconciliation counter on the skip toggle.
|
||||
// A skip the person records clears the automatic marker.
|
||||
data: {
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoRecorded: false,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
// The row stopped being taken; refund whatever its
|
||||
// consumption stamp recorded (no-op for a never-consumed row).
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: intakeId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
// snoozed: snoozedUntil lives on the Medication row.
|
||||
const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min
|
||||
await prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: until },
|
||||
});
|
||||
return prisma.medicationIntakeEvent.findUnique({
|
||||
where: { id: intakeId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The audit row, the event, the caches and the rollup rows of the day the
|
||||
* event sat on and, after a slot move, of the day it moved to.
|
||||
*/
|
||||
async function recordAfterUpdate(
|
||||
ctx: StatusContext,
|
||||
data: IntakeStatusUpdate,
|
||||
updated: UpdatedRow | null,
|
||||
userTzForHook: string,
|
||||
): Promise<Date | undefined> {
|
||||
const { user, existing } = ctx;
|
||||
const { intakeId, status } = data;
|
||||
await auditLog("medications.intake.update", {
|
||||
userId: user.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
details: { intakeId, status },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.update" },
|
||||
meta: { intakeId, status },
|
||||
});
|
||||
|
||||
// Bust the medications + compliance + achievement
|
||||
// caches for this user so the next read reflects the dose change.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// Refresh the persistent compliance rollup row for
|
||||
// the affected day so the next read after the cache miss returns
|
||||
// the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort:
|
||||
// a populator failure annotates ops without blocking the response.
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: existing.scheduledFor,
|
||||
tz: userTzForHook,
|
||||
});
|
||||
// A slot move re-binds the dose onto a DIFFERENT instant,
|
||||
// possibly a different local day. Recompute the target day too, or the
|
||||
// source day self-corrects while the destination keeps its stale tuple.
|
||||
const movedTo = updated?.scheduledFor;
|
||||
if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) {
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: movedTo,
|
||||
tz: userTzForHook,
|
||||
});
|
||||
}
|
||||
return movedTo;
|
||||
}
|
||||
|
||||
/** The tails that never affect the response. */
|
||||
function startTails(
|
||||
ctx: StatusContext,
|
||||
status: IntakeStatusUpdate["status"],
|
||||
movedTo: Date | undefined,
|
||||
userTzForHook: string,
|
||||
): void {
|
||||
const { user, existing } = ctx;
|
||||
// (#22) — silent cross-device intake sync. Wake the user's OTHER
|
||||
// iOS devices so a running Live Activity / Home-Screen widget reconciles
|
||||
// the dose state changed here. APNs-only, best-effort, coalesced per user:
|
||||
// the canonical row is already persisted, so a sync-push miss never
|
||||
// affects the response. The originating device (the one that POSTed) is
|
||||
// excluded via its `X-Device-Id` (= registered `Device.token`).
|
||||
queueMedicationIntakeSync({
|
||||
userId: user.id,
|
||||
originDeviceToken: ctx.originDeviceToken,
|
||||
});
|
||||
|
||||
// PWA-only equivalent of the Live Activity end: when a dose is
|
||||
// resolved (taken / skipped), push a `type:"clear"` to the user's Web Push
|
||||
// subscriptions so the still-pending dose-due reminder for this slot is
|
||||
// closed by the service worker (matched on the stable slot tag) and the app
|
||||
// badge re-reflects the outstanding-dose count. A snooze leaves the dose
|
||||
// outstanding, so it neither clears the reminder nor changes the badge.
|
||||
if (status === "taken" || status === "skipped") {
|
||||
void (async () => {
|
||||
const badgeCount = await countOutstandingDosesToday(
|
||||
user.id,
|
||||
userTzForHook,
|
||||
);
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
|
||||
badgeCount,
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// "Somebody else marked your dose". The helper refuses on self and
|
||||
// on a snooze, resolves both names itself, and never throws.
|
||||
//
|
||||
// Fire and forget, like every neighbour above. The dispatcher awaits each
|
||||
// channel in the cascade, so an unreachable one would have added its whole
|
||||
// timeout to the caregiver's request — the person standing next to the
|
||||
// patient waiting for a dose to register. The helper never throws, and the
|
||||
// owner learning a moment later is the right trade against the tick being
|
||||
// slow. The integration test already polls the ledger rather than relying on
|
||||
// the await, so nothing about the proof weakens.
|
||||
void notifyDelegatedIntake({
|
||||
ownerId: user.id,
|
||||
actorId: ctx.actorId,
|
||||
medicationId: existing.medicationId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply the status update to the event the route found. */
|
||||
export async function updateIntakeStatus(
|
||||
ctx: StatusContext,
|
||||
data: IntakeStatusUpdate,
|
||||
): Promise<IntakeStatusOutcome> {
|
||||
const { user, existing } = ctx;
|
||||
const { intakeId, status } = data;
|
||||
|
||||
if (refusesUntrackedIntake(existing)) return { kind: "untracked" };
|
||||
|
||||
const refused = await refuseDelegatedChange({
|
||||
userId: user.id,
|
||||
actorId: ctx.actorId,
|
||||
existing,
|
||||
intakeId,
|
||||
status,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
if (refused) return refused;
|
||||
|
||||
const site = await resolveStatusInjectionSite({
|
||||
userId: user.id,
|
||||
existing,
|
||||
injectionSite: data.injectionSite,
|
||||
status,
|
||||
});
|
||||
if (site.kind === "refused") return site;
|
||||
|
||||
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
let updated: UpdatedRow | null;
|
||||
if (status === "taken") {
|
||||
const taken = await applyTakenStatus({
|
||||
userId: user.id,
|
||||
intakeId,
|
||||
existing,
|
||||
takenAt: data.takenAt,
|
||||
forceSlotInstant: data.forceSlotInstant,
|
||||
resolvedInjectionSite: site.site,
|
||||
userTz: userTzForHook,
|
||||
});
|
||||
if (taken.kind === "refused") return taken;
|
||||
updated = taken.row;
|
||||
} else {
|
||||
updated = await applySkipOrSnooze(ctx, data);
|
||||
}
|
||||
|
||||
const movedTo = await recordAfterUpdate(ctx, data, updated, userTzForHook);
|
||||
startTails(ctx, status, movedTo, userTzForHook);
|
||||
|
||||
return { kind: "updated", row: updated };
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* What a medication edit checks before anything is written: the stored
|
||||
* pre-image, the record-only rule, the category, the as-needed and one-shot
|
||||
* shapes, the primary-schedule grace bridge and the course window. Each
|
||||
* refusal leaves the medication as it was.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
checkCurrentWindow,
|
||||
courseRefusalResponse,
|
||||
} from "@/lib/medications/courses";
|
||||
import type { MedicationEdit } from "./schedule-normalise";
|
||||
|
||||
/** A refusal the route answers with `apiError(message, status, meta)`. */
|
||||
export interface MedicationEditRefusal {
|
||||
ok: false;
|
||||
message: string;
|
||||
status: number;
|
||||
meta?: { errorCode: string };
|
||||
}
|
||||
|
||||
const EDIT_SELECT = {
|
||||
active: true,
|
||||
createdAt: true,
|
||||
// `asNeeded` + the schedule count feed the as-needed invariants: a flip
|
||||
// to as-needed must end schedule-less, a flip back to scheduled must end
|
||||
// with at least one schedule.
|
||||
asNeeded: true,
|
||||
// The tracking transition (#1033) needs the before.
|
||||
trackIntake: true,
|
||||
// `autoMarkTakenSince` is stamped only on an off→on flip.
|
||||
autoMarkTaken: true,
|
||||
// The pre-image the audit row carries (C4).
|
||||
name: true,
|
||||
dose: true,
|
||||
endsOn: true,
|
||||
// The course check (#1024) needs the stored window.
|
||||
startsOn: true,
|
||||
oneShot: true,
|
||||
_count: { select: { schedules: true } },
|
||||
} satisfies Prisma.MedicationSelect;
|
||||
|
||||
export type EditableMedication = Prisma.MedicationGetPayload<{
|
||||
select: typeof EDIT_SELECT;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The narrow pre-image the edit reads after the route's ownership guard
|
||||
* passed: `active` for the pausedAt derivation, and the rest for the
|
||||
* invariants and the audit row. Null for the route's 404.
|
||||
*/
|
||||
export async function findMedicationForEdit(
|
||||
id: string,
|
||||
): Promise<EditableMedication | null> {
|
||||
return prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: EDIT_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ── A record-only schedule is only replaced knowingly (#1033) ──
|
||||
*
|
||||
* A medication with intake tracking off is served with `schedules: []`
|
||||
* and its stored schedule in `recordedSchedules`, so a client that
|
||||
* predates the field arms no reminder from it. The price is that such a
|
||||
* client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 /
|
||||
* 1.0.4) opens a medication without schedule rows in its editor as
|
||||
* "daily at 08:00"; a rename or dose edit sends no `schedules` (its
|
||||
* editor diffs against that same prefill), but any touch of a schedule
|
||||
* field sends a full replacement built from the 08:00 default plus
|
||||
* `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would
|
||||
* overwrite the schedule the person kept on record with one they never
|
||||
* chose (or flip it to as-needed and delete the rows).
|
||||
*
|
||||
* Rule: on a medication whose intake tracking is off, a write that does
|
||||
* not name `trackIntake` leaves the schedule shape alone. `schedules`,
|
||||
* `asNeeded` and `oneShot` are dropped from it; every other field (name,
|
||||
* dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies
|
||||
* as sent. A client that knows about the switch names it on every
|
||||
* schedule save (the web editor and wizard do), and those writes apply.
|
||||
*/
|
||||
export function applyRecordOnlyRule(
|
||||
existing: EditableMedication,
|
||||
data: MedicationEdit,
|
||||
id: string,
|
||||
): MedicationEdit {
|
||||
const keepRecordedSchedule =
|
||||
existing.trackIntake === false &&
|
||||
data.trackIntake === undefined &&
|
||||
(data.schedules !== undefined ||
|
||||
data.asNeeded !== undefined ||
|
||||
data.oneShot !== undefined);
|
||||
const input = keepRecordedSchedule
|
||||
? {
|
||||
...data,
|
||||
schedules: undefined,
|
||||
asNeeded: undefined,
|
||||
oneShot: undefined,
|
||||
}
|
||||
: data;
|
||||
if (keepRecordedSchedule) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.update.record_schedule_kept",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
dropped_schedules: data.schedules !== undefined,
|
||||
dropped_as_needed: data.asNeeded !== undefined,
|
||||
dropped_one_shot: data.oneShot !== undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* ── As-needed invariants (#316) ──
|
||||
*
|
||||
* An as-needed medication ends schedule-less, always. The Zod layer
|
||||
* already rejects a populated `schedules` array alongside
|
||||
* `asNeeded: true`; the route covers the cross-row cases the schema
|
||||
* cannot see:
|
||||
* - flipping an existing scheduled medication to as-needed
|
||||
* requires `schedules: []` in the same request (keeping the old
|
||||
* rows alongside the flag is a 422, per the feature contract);
|
||||
* - flipping as-needed OFF requires the medication to end with at
|
||||
* least one schedule (the wizard always sends the full array).
|
||||
*/
|
||||
export function refuseAsNeededShape(
|
||||
existing: EditableMedication,
|
||||
asNeeded: boolean | undefined,
|
||||
schedules: MedicationEdit["schedules"],
|
||||
): MedicationEditRefusal | null {
|
||||
const effectiveAsNeeded = asNeeded ?? existing.asNeeded;
|
||||
if (effectiveAsNeeded) {
|
||||
const endsScheduleless = schedules
|
||||
? schedules.length === 0
|
||||
: existing._count.schedules === 0;
|
||||
if (!endsScheduleless) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"An as-needed medication cannot carry schedules (send schedules: [] to clear them)",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
asNeeded === false &&
|
||||
existing.asNeeded &&
|
||||
(!schedules || schedules.length === 0)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "A scheduled medication requires at least one schedule",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface GracePatch {
|
||||
scheduleId: string;
|
||||
reminderGraceMinutes: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ── Primary-schedule grace bridge ──
|
||||
*
|
||||
* The detail-page settings section saves the reminder-window in
|
||||
* one PUT carrying only `{ reminderGraceMinutes }` at the top
|
||||
* level (no full `schedules` array). Map it onto the medication's
|
||||
* primary schedule before the Prisma update so the persisted
|
||||
* shape stays per-schedule and the engine reads it from the same
|
||||
* column as the wizard write-path. A full `schedules` array on
|
||||
* the same request takes precedence — the wizard already declares
|
||||
* the grace per row and we never want to overwrite that intent.
|
||||
*/
|
||||
export async function findPrimaryScheduleGracePatch(
|
||||
id: string,
|
||||
topLevelGraceMinutes: number | null | undefined,
|
||||
schedules: MedicationEdit["schedules"],
|
||||
): Promise<GracePatch | null> {
|
||||
let primaryScheduleGracePatch: GracePatch | null = null;
|
||||
if (topLevelGraceMinutes !== undefined && !schedules) {
|
||||
const primary = await prisma.medicationSchedule.findFirst({
|
||||
where: { medicationId: id },
|
||||
orderBy: { windowStart: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (primary) {
|
||||
primaryScheduleGracePatch = {
|
||||
scheduleId: primary.id,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
};
|
||||
}
|
||||
}
|
||||
return primaryScheduleGracePatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* ── Route invariants for the scheduling primitives ──
|
||||
*
|
||||
* Mirrors the create: oneShot ⇒ at-most-one schedule + no
|
||||
* recurrence on it; endsOn normalised to startsOn for one-shot;
|
||||
* recurring default = FREQ=DAILY when nothing else is set;
|
||||
* timesOfDay dual-write from windowStart when absent.
|
||||
*
|
||||
* The PUT replaces all schedules wholesale, so the invariants run
|
||||
* against the incoming `schedules` array (if provided) the same
|
||||
* way they do on create.
|
||||
*/
|
||||
export function refuseOneShotShape(
|
||||
oneShot: boolean | undefined,
|
||||
schedules: MedicationEdit["schedules"],
|
||||
): MedicationEditRefusal | null {
|
||||
if (oneShot === true && schedules) {
|
||||
if (schedules.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "A one-shot medication can have at most one schedule",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
const s = schedules[0];
|
||||
if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window a PUT sets is the latest course (#1024). Check it against the
|
||||
* other courses BEFORE the row changes, so a refused window leaves the
|
||||
* medication as it was.
|
||||
*/
|
||||
export async function refuseCourseWindow(args: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
timeZone: string;
|
||||
nextWindow: { startsOn: Date | null; endsOn: Date | null };
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
}): Promise<MedicationEditRefusal | null> {
|
||||
const refusal = await checkCurrentWindow({
|
||||
userId: args.userId,
|
||||
medicationId: args.medicationId,
|
||||
timeZone: args.timeZone,
|
||||
...args.nextWindow,
|
||||
oneShot: args.oneShot,
|
||||
createdAt: args.createdAt,
|
||||
});
|
||||
if (refusal) {
|
||||
const r = courseRefusalResponse(refusal);
|
||||
return {
|
||||
ok: false,
|
||||
message: r.message,
|
||||
status: r.status,
|
||||
meta: { errorCode: r.errorCode },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* The incoming schedules of a medication edit, normalised once.
|
||||
*
|
||||
* The same values feed the nested create payload AND the revision
|
||||
* compare/snapshot, so the material-change gate sees exactly what will be
|
||||
* persisted. The invariants mirror the create: recurring default
|
||||
* `FREQ=DAILY` when nothing else is set, `timesOfDay` dual-written from
|
||||
* `windowStart` when absent, and the window pulled to the dose times when a
|
||||
* time falls outside it.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
|
||||
import type { updateMedicationSchema } from "@/lib/validations/medication";
|
||||
|
||||
export type MedicationEdit = z.output<typeof updateMedicationSchema>;
|
||||
type ScheduleInput = NonNullable<MedicationEdit["schedules"]>[number];
|
||||
|
||||
/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
|
||||
function hhmmToMinutes(value: string): number {
|
||||
return hhmmToMinutesOrNull(value) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a schedule's `windowStart` / `windowEnd` with its effective
|
||||
* `timesOfDay`. The schedule editor can change only the dose times while the
|
||||
* client echoes the previous window back; a time outside the window then
|
||||
* breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]`
|
||||
* (the window-status pill, the reminder phases, the legacy compliance
|
||||
* walker). When ANY time falls outside the window — including the overnight
|
||||
* `windowEnd < windowStart` shape, checked with wrap-around membership —
|
||||
* the window is pulled to the min/max of the times so the persisted row is
|
||||
* self-consistent. A window that already covers every time is left
|
||||
* byte-identical (explicitly wider windows are a feature).
|
||||
*/
|
||||
function reconcileScheduleWindow(
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
timesOfDay: string[],
|
||||
): { windowStart: string; windowEnd: string } {
|
||||
if (timesOfDay.length === 0) return { windowStart, windowEnd };
|
||||
const startMins = hhmmToMinutes(windowStart);
|
||||
const endMins = hhmmToMinutes(windowEnd);
|
||||
const overnight = endMins < startMins;
|
||||
const inWindow = (t: number): boolean =>
|
||||
overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins;
|
||||
const sorted = [...timesOfDay].sort(
|
||||
(a, b) => hhmmToMinutes(a) - hhmmToMinutes(b),
|
||||
);
|
||||
const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t)));
|
||||
if (allInside) return { windowStart, windowEnd };
|
||||
return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] };
|
||||
}
|
||||
|
||||
function normaliseSchedule(s: ScheduleInput, oneShot: boolean | undefined) {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
// PRN carries no cadence, so never default it.
|
||||
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
|
||||
const isPrn = s.scheduleType === "PRN";
|
||||
const defaultedRrule =
|
||||
oneShot !== true &&
|
||||
!isPrn &&
|
||||
s.rrule === undefined &&
|
||||
s.rollingIntervalDays === undefined &&
|
||||
!hasLegacyDays
|
||||
? "FREQ=DAILY"
|
||||
: s.rrule;
|
||||
|
||||
// Invariant 3 — dual-write timesOfDay from windowStart.
|
||||
const effectiveTimesOfDay =
|
||||
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart];
|
||||
|
||||
// Invariant 4 — window / times consistency. See
|
||||
// `reconcileScheduleWindow`: a times-only edit that echoes the
|
||||
// stale window back gets the window pulled to the min/max of
|
||||
// the new times.
|
||||
const window = reconcileScheduleWindow(
|
||||
s.windowStart,
|
||||
s.windowEnd,
|
||||
effectiveTimesOfDay,
|
||||
);
|
||||
|
||||
const serializedDaysOfWeek = serializeScheduleRecurrence({
|
||||
daysOfWeek: s.daysOfWeek ?? [],
|
||||
intervalWeeks: s.intervalWeeks ?? 1,
|
||||
});
|
||||
|
||||
return {
|
||||
createData: {
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
// #219 — per-schedule inventory units, field-by-field. A schedule
|
||||
// replace re-creates the rows, so absent leaves the column NULL
|
||||
// and the consume hook inherits the medication-level value.
|
||||
...(s.unitsPerDose !== undefined && {
|
||||
unitsPerDose: s.unitsPerDose,
|
||||
}),
|
||||
daysOfWeek: serializedDaysOfWeek,
|
||||
// First-class times-of-day.
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
...(s.reminderGraceMinutes !== undefined && {
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
}),
|
||||
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
|
||||
...(s.rollingIntervalDays !== undefined && {
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
}),
|
||||
// Schedule type + cyclic weeks, field-by-field.
|
||||
...(s.scheduleType !== undefined && {
|
||||
scheduleType: s.scheduleType,
|
||||
}),
|
||||
...(s.cyclicOnWeeks !== undefined && {
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
}),
|
||||
...(s.cyclicOffWeeks !== undefined && {
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
}),
|
||||
// Per-dose configurable on-time windows. A `schedules`
|
||||
// replace re-creates the rows, so the column is re-written each
|
||||
// time; absent leaves it NULL (default ±1h derivation).
|
||||
...(s.doseWindows !== undefined && {
|
||||
doseWindows: s.doseWindows,
|
||||
}),
|
||||
},
|
||||
// Mirror of the row the create above will persist (defaults applied),
|
||||
// shaped for the cadence compare.
|
||||
snapshot: toRevisionPayloadEntry({
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
daysOfWeek: serializedDaysOfWeek,
|
||||
rrule: defaultedRrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
scheduleType: s.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
doseWindows: s.doseWindows ?? null,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type NormalisedSchedule = ReturnType<typeof normaliseSchedule>;
|
||||
|
||||
/** The incoming schedules, normalised; undefined when the edit sends none. */
|
||||
export function normaliseSchedules(
|
||||
schedules: MedicationEdit["schedules"],
|
||||
oneShot: boolean | undefined,
|
||||
): NormalisedSchedule[] | undefined {
|
||||
return schedules?.map((s) => normaliseSchedule(s, oneShot));
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Edit one medication: `PUT /api/medications/{id}`.
|
||||
*
|
||||
* The route authenticates (MANAGE over the `medications` section), runs the
|
||||
* ownership guard, reads the pre-image through `findMedicationForEdit` and
|
||||
* parses the body; the record-only rule, the invariants, the transaction and
|
||||
* everything after it run here, in the order they always ran. The outcome is
|
||||
* a refusal the route answers with `apiError`, or the edited medication.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
enqueueSupplementNutrientRecompute,
|
||||
medicationHasComposition,
|
||||
} from "@/lib/medications/ingredients/recompute-job";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
isAssignableCategory,
|
||||
resolveMedicationCategories,
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import {
|
||||
CourseWriteError,
|
||||
courseRefusalResponse,
|
||||
resolveCourseFields,
|
||||
} from "@/lib/medications/courses";
|
||||
import { refreshDoseDay } from "@/lib/medications/dose-day-refresh";
|
||||
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
|
||||
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
applyRecordOnlyRule,
|
||||
findPrimaryScheduleGracePatch,
|
||||
refuseAsNeededShape,
|
||||
refuseCourseWindow,
|
||||
refuseOneShotShape,
|
||||
type EditableMedication,
|
||||
type GracePatch,
|
||||
type MedicationEditRefusal,
|
||||
} from "./preflight";
|
||||
import { normaliseSchedules, type MedicationEdit } from "./schedule-normalise";
|
||||
import {
|
||||
createEraTrail,
|
||||
writeMedicationEdit,
|
||||
type EraTrail,
|
||||
type MedicationEditWrite,
|
||||
} from "./write";
|
||||
|
||||
type EditUser = Pick<SessionUser, "id" | "timezone">;
|
||||
type EditedMedication = Awaited<ReturnType<typeof writeMedicationEdit>>;
|
||||
|
||||
export type MedicationEditOutcome =
|
||||
MedicationEditRefusal | { ok: true; body: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* Everything the edit checks and derives before the transaction, in the
|
||||
* order the route always ran it. A refusal leaves the medication as it was.
|
||||
*/
|
||||
async function planMedicationEdit(input: {
|
||||
user: EditUser;
|
||||
id: string;
|
||||
existing: EditableMedication;
|
||||
data: MedicationEdit;
|
||||
}): Promise<
|
||||
| MedicationEditRefusal
|
||||
| {
|
||||
ok: true;
|
||||
write: MedicationEditWrite;
|
||||
primaryScheduleGracePatch: GracePatch | null;
|
||||
}
|
||||
> {
|
||||
const { user, id, existing } = input;
|
||||
const edit = applyRecordOnlyRule(existing, input.data, id);
|
||||
const {
|
||||
category,
|
||||
active,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = edit;
|
||||
|
||||
// A custom category must be one of this record's own (#1041).
|
||||
if (
|
||||
category !== undefined &&
|
||||
!(await isAssignableCategory(user.id, category))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Unknown medication category",
|
||||
status: 422,
|
||||
meta: { errorCode: "medications.category.unknown" },
|
||||
};
|
||||
}
|
||||
|
||||
// Intake tracking transition (#1033). Off keeps the schedule as a record;
|
||||
// the live era from here on expects nothing.
|
||||
const wasTracked = existing.trackIntake !== false;
|
||||
const willTrack = trackIntake ?? wasTracked;
|
||||
const trackingChanged = wasTracked !== willTrack;
|
||||
|
||||
const asNeededRefusal = refuseAsNeededShape(existing, asNeeded, schedules);
|
||||
if (asNeededRefusal) return asNeededRefusal;
|
||||
|
||||
const primaryScheduleGracePatch = await findPrimaryScheduleGracePatch(
|
||||
id,
|
||||
topLevelGraceMinutes,
|
||||
schedules,
|
||||
);
|
||||
|
||||
const oneShotRefusal = refuseOneShotShape(oneShot, schedules);
|
||||
if (oneShotRefusal) return oneShotRefusal;
|
||||
|
||||
// Normalise endsOn for one-shot. `oneShot === true` + `startsOn`
|
||||
// means the dose is the start date; endsOn auto-matches.
|
||||
const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn;
|
||||
|
||||
const touchesWindow =
|
||||
startsOn !== undefined ||
|
||||
normalisedEndsOn !== undefined ||
|
||||
oneShot !== undefined;
|
||||
const nextWindow = {
|
||||
startsOn: startsOn !== undefined ? startsOn : existing.startsOn,
|
||||
endsOn: normalisedEndsOn !== undefined ? normalisedEndsOn : existing.endsOn,
|
||||
};
|
||||
const courseTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
if (touchesWindow) {
|
||||
const refusal = await refuseCourseWindow({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: courseTz,
|
||||
nextWindow,
|
||||
oneShot: oneShot ?? existing.oneShot,
|
||||
createdAt: existing.createdAt,
|
||||
});
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
|
||||
const pausedAtPatch =
|
||||
active === undefined
|
||||
? {}
|
||||
: active
|
||||
? { pausedAt: null as Date | null }
|
||||
: existing.active
|
||||
? { pausedAt: new Date() }
|
||||
: {};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
primaryScheduleGracePatch,
|
||||
write: {
|
||||
user,
|
||||
id,
|
||||
existing,
|
||||
input: edit,
|
||||
normalisedSchedules: normaliseSchedules(schedules, oneShot),
|
||||
wasTracked,
|
||||
willTrack,
|
||||
trackingChanged,
|
||||
pausedAtPatch,
|
||||
touchesWindow,
|
||||
nextWindow,
|
||||
courseTz,
|
||||
trail: createEraTrail(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh each dose day the tombstoned slots sat on, now that the tombstones
|
||||
* are committed, then apply the primary-schedule grace bridge. Both run
|
||||
* after the transaction so a failure here doesn't roll the edit back.
|
||||
*/
|
||||
async function settleAfterCommit(
|
||||
write: MedicationEditWrite,
|
||||
medication: EditedMedication,
|
||||
primaryScheduleGracePatch: GracePatch | null,
|
||||
): Promise<EditedMedication> {
|
||||
const { user, id, trail } = write;
|
||||
// Best-effort like every dose write-hook: a failure is reported, never
|
||||
// fails the edit.
|
||||
for (const dayKey of trail.staleDoseDays) {
|
||||
await refreshDoseDay({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
dayKey,
|
||||
tz: user.timezone || DEFAULT_TIMEZONE,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-read the row so the response shape carries the converged grace value.
|
||||
if (primaryScheduleGracePatch) {
|
||||
await prisma.medicationSchedule.update({
|
||||
where: { id: primaryScheduleGracePatch.scheduleId },
|
||||
data: {
|
||||
reminderGraceMinutes: primaryScheduleGracePatch.reminderGraceMinutes,
|
||||
},
|
||||
});
|
||||
const refreshed = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (refreshed) {
|
||||
return refreshed;
|
||||
}
|
||||
}
|
||||
return medication;
|
||||
}
|
||||
|
||||
/**
|
||||
* An iPhone app build that predates custom categories (#1041) decodes an
|
||||
* unknown category as OTHER and sends it back on every edit, which would
|
||||
* overwrite the person's own category with OTHER. A native client (Bearer)
|
||||
* sending OTHER over a custom category keeps the custom one; moving a custom
|
||||
* category back to OTHER stays possible on the web.
|
||||
*/
|
||||
async function writeCategory(
|
||||
id: string,
|
||||
category: string | undefined,
|
||||
authMethod: string,
|
||||
) {
|
||||
let categoryToWrite = category;
|
||||
if (category === "OTHER" && authMethod === "bearer") {
|
||||
const current = (await resolveMedicationCategories([id]))[id];
|
||||
if (current?.category.startsWith("custom:")) {
|
||||
categoryToWrite = undefined;
|
||||
annotate({
|
||||
action: { name: "medication.update.custom_category_kept" },
|
||||
meta: { entity_id: id },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (categoryToWrite !== undefined)
|
||||
await setMedicationCategory(id, categoryToWrite);
|
||||
return (await resolveMedicationCategories([id]))[id];
|
||||
}
|
||||
|
||||
/** The audit row: C4 on the medication row itself, C7 on the schedule. */
|
||||
async function auditMedicationEdit(
|
||||
write: MedicationEditWrite,
|
||||
medication: EditedMedication,
|
||||
ipAddress: string | null,
|
||||
) {
|
||||
const { user, id, existing, trail } = write;
|
||||
const {
|
||||
replacedCadence,
|
||||
replacedScheduleCount,
|
||||
tombstonedSlotCount,
|
||||
tombstonedFrom,
|
||||
tombstonedTo,
|
||||
}: EraTrail = trail;
|
||||
await auditLog("medication.update", {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
// C4 on what the medication row itself lost, and C7 on the schedule:
|
||||
// the cadence that was replaced, and how many pending slots went with
|
||||
// it over which days.
|
||||
details: {
|
||||
medicationId: id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
name: existing.name,
|
||||
dose: existing.dose,
|
||||
active: existing.active,
|
||||
asNeeded: existing.asNeeded,
|
||||
trackIntake: existing.trackIntake,
|
||||
// Switching automatic recording on or off is a change
|
||||
// to how doses are recorded, so the row names it.
|
||||
autoMarkTaken: existing.autoMarkTaken,
|
||||
endsOn: existing.endsOn,
|
||||
},
|
||||
after: {
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
active: medication.active,
|
||||
asNeeded: medication.asNeeded,
|
||||
trackIntake: medication.trackIntake,
|
||||
autoMarkTaken: medication.autoMarkTaken,
|
||||
endsOn: medication.endsOn,
|
||||
},
|
||||
}),
|
||||
...(replacedScheduleCount !== null
|
||||
? {
|
||||
replacedCadence,
|
||||
replacedScheduleCount,
|
||||
tombstonedSlotCount,
|
||||
tombstonedFrom: tombstonedFrom?.toISOString() ?? null,
|
||||
tombstonedTo: tombstonedTo?.toISOString() ?? null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Edit one medication the route found and authorised. */
|
||||
export async function updateMedication(input: {
|
||||
user: EditUser;
|
||||
authMethod: string;
|
||||
id: string;
|
||||
existing: EditableMedication;
|
||||
data: MedicationEdit;
|
||||
ipAddress: string | null;
|
||||
}): Promise<MedicationEditOutcome> {
|
||||
const { user, id } = input;
|
||||
const plan = await planMedicationEdit(input);
|
||||
if (!plan.ok) return plan;
|
||||
const { write } = plan;
|
||||
|
||||
let medication: EditedMedication;
|
||||
try {
|
||||
medication = await writeMedicationEdit(write);
|
||||
} catch (err) {
|
||||
// A concurrent course write landed between the check above and the
|
||||
// course write. The transaction rolled back: nothing was written.
|
||||
if (err instanceof CourseWriteError) {
|
||||
const r = courseRefusalResponse(err.refusal);
|
||||
return {
|
||||
ok: false,
|
||||
message: r.message,
|
||||
status: r.status,
|
||||
meta: { errorCode: r.errorCode },
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
medication = await settleAfterCommit(
|
||||
write,
|
||||
medication,
|
||||
plan.primaryScheduleGracePatch,
|
||||
);
|
||||
|
||||
const resolvedCategory = await writeCategory(
|
||||
id,
|
||||
write.input.category,
|
||||
input.authMethod,
|
||||
);
|
||||
const normalizedCategory = resolvedCategory?.category ?? "OTHER";
|
||||
|
||||
// A changed units-per-dose (on the medication or on a slot) changes what
|
||||
// every taken dose added, so a medication with a composition has its doses
|
||||
// recomputed. Cheap and coalesced when nothing moved.
|
||||
if (await medicationHasComposition(user.id, id)) {
|
||||
await enqueueSupplementNutrientRecompute(user.id);
|
||||
}
|
||||
|
||||
await auditMedicationEdit(write, medication, input.ipAddress);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.update",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
// Bust per-user medications + compliance + achievement caches so the next
|
||||
// read reflects the schedule change.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
categoryLabel: resolvedCategory?.categoryLabel ?? null,
|
||||
// The courses, as every medication read carries them (#1024).
|
||||
...(
|
||||
await resolveCourseFields([medication], new Date(), write.courseTz)
|
||||
).get(id),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* The write of a medication edit: everything the PUT changes on the
|
||||
* medication, its schedule and its course commits as one transaction.
|
||||
*
|
||||
* The course write can still be refused after the preflight check (another
|
||||
* request wrote a course in between); with the row update committed first,
|
||||
* the client got the refusal while the name, the schedule and `oneShot` were
|
||||
* already changed. Inside one transaction a refusal rolls all of it back. The
|
||||
* dose-day refresh and the grace bridge stay outside: both read or write what
|
||||
* this commit settles, and neither may undo it.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { setCurrentWindow } from "@/lib/medications/courses";
|
||||
import {
|
||||
schedulesMateriallyDiffer,
|
||||
toRevisionPayloadEntry,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import { moveAutoTakeFloorToNow } from "@/lib/medications/auto-take";
|
||||
import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import type { EditableMedication } from "./preflight";
|
||||
import type { MedicationEdit, NormalisedSchedule } from "./schedule-normalise";
|
||||
|
||||
/**
|
||||
* C7. What a schedule replacement destroys, captured for the audit row: the
|
||||
* cadence that was there and the pending slots that were tombstoned with it.
|
||||
* A feed line saying "changed a medication" cannot answer "back to what?",
|
||||
* and unlike a row a schedule keeps ACTING after the manager's grant ends.
|
||||
* `staleDoseDays` holds the dose days the tombstoned slots sat on, refreshed
|
||||
* once the transaction has committed the tombstones.
|
||||
*/
|
||||
export interface EraTrail {
|
||||
replacedCadence: string | null;
|
||||
replacedScheduleCount: number | null;
|
||||
tombstonedSlotCount: number | null;
|
||||
tombstonedFrom: Date | null;
|
||||
tombstonedTo: Date | null;
|
||||
staleDoseDays: Set<string>;
|
||||
}
|
||||
|
||||
export function createEraTrail(): EraTrail {
|
||||
return {
|
||||
replacedCadence: null,
|
||||
replacedScheduleCount: null,
|
||||
tombstonedSlotCount: null,
|
||||
tombstonedFrom: null,
|
||||
tombstonedTo: null,
|
||||
staleDoseDays: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MedicationEditWrite {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
id: string;
|
||||
existing: EditableMedication;
|
||||
input: MedicationEdit;
|
||||
normalisedSchedules: NormalisedSchedule[] | undefined;
|
||||
wasTracked: boolean;
|
||||
willTrack: boolean;
|
||||
trackingChanged: boolean;
|
||||
pausedAtPatch: { pausedAt?: Date | null };
|
||||
touchesWindow: boolean;
|
||||
nextWindow: { startsOn: Date | null; endsOn: Date | null };
|
||||
courseTz: string;
|
||||
trail: EraTrail;
|
||||
}
|
||||
|
||||
type RevisionSourceRow = Parameters<typeof toRevisionPayloadEntry>[0];
|
||||
|
||||
/**
|
||||
* Tombstone today's and future open pending rows of this medication. Runs on
|
||||
* a schedule replace (the old anchors are stale) and when intake tracking is
|
||||
* switched off (nothing is due any more).
|
||||
*/
|
||||
async function tombstoneOpenSlots(
|
||||
db: Prisma.TransactionClient,
|
||||
edit: MedicationEditWrite,
|
||||
): Promise<void> {
|
||||
const { user, id, trail } = edit;
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
// auto-miss later, and depress compliance. Tombstone today's and
|
||||
// future open pending rows (never an actioned row — `takenAt`,
|
||||
// `skipped`, and `autoMissed` are user-visible history) and bump
|
||||
// `syncVersion` so delta-sync clients drop them. The projector and
|
||||
// the reminder worker re-mint the anchors for the new times on their
|
||||
// next pass — the route does not pre-create them.
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
|
||||
const tombstoneWhere = {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
scheduledFor: { gte: todayStart },
|
||||
} as const;
|
||||
// Capture the affected slots BEFORE the tombstone so the compliance
|
||||
// rollups for those days can be recomputed after the commit —
|
||||
// `updateMany` does not return the touched rows. Without the
|
||||
// recompute a tombstoned pending kept counting as `scheduled` in
|
||||
// the rollup row until the next event-driven write for that day,
|
||||
// depressing the rate the cards read in the meantime.
|
||||
const tombstoned = await db.medicationIntakeEvent.findMany({
|
||||
where: tombstoneWhere,
|
||||
select: { scheduledFor: true },
|
||||
});
|
||||
// The count and the day range of the anchors that went.
|
||||
trail.tombstonedSlotCount = tombstoned.length;
|
||||
for (const row of tombstoned) {
|
||||
if (
|
||||
trail.tombstonedFrom === null ||
|
||||
row.scheduledFor < trail.tombstonedFrom
|
||||
) {
|
||||
trail.tombstonedFrom = row.scheduledFor;
|
||||
}
|
||||
if (trail.tombstonedTo === null || row.scheduledFor > trail.tombstonedTo) {
|
||||
trail.tombstonedTo = row.scheduledFor;
|
||||
}
|
||||
}
|
||||
await db.medicationIntakeEvent.updateMany({
|
||||
where: tombstoneWhere,
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
// Deduped via the user-tz day key.
|
||||
for (const row of tombstoned) {
|
||||
trail.staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
|
||||
}
|
||||
}
|
||||
|
||||
function revisionEntryOf(row: RevisionSourceRow) {
|
||||
return toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
/** One archived era covering `[previous validUntil | createdAt, now)`. */
|
||||
async function archiveEra(
|
||||
tx: Prisma.TransactionClient,
|
||||
edit: MedicationEditWrite,
|
||||
payload: unknown[],
|
||||
meta: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const { id, existing } = edit;
|
||||
const lastRevision = await tx.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
/** The cadence a schedule replace destroys, as one comparable line. */
|
||||
function cadenceOf(previousRows: RevisionSourceRow[]): string | null {
|
||||
return previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A schedule replace. Effective dating: before the wholesale replace wipes
|
||||
* the old rows, archive them as ONE revision covering `[previous validUntil |
|
||||
* medication.createdAt, now)` — but only when a cadence-relevant field
|
||||
* actually changes. A no-op edit (the Zeitplan tab echoing the same rows
|
||||
* back) must not mint a phantom era.
|
||||
*/
|
||||
async function replaceSchedules(
|
||||
tx: Prisma.TransactionClient,
|
||||
edit: MedicationEditWrite,
|
||||
normalisedSchedules: NormalisedSchedule[],
|
||||
): Promise<void> {
|
||||
const { id, existing, input, wasTracked, trackingChanged, trail } = edit;
|
||||
const previousRows = await tx.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map(revisionEntryOf);
|
||||
trail.replacedScheduleCount = previousRows.length;
|
||||
trail.replacedCadence = cadenceOf(previousRows);
|
||||
|
||||
// A stretch with intake tracking off expected nothing (#1033), so it
|
||||
// archives with an empty payload whatever the rows said; and a tracking
|
||||
// transition is itself an era boundary even when the rows stay the same.
|
||||
const archivedEntries = wasTracked ? previousEntries : [];
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
(trackingChanged ||
|
||||
!wasTracked ||
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
))
|
||||
) {
|
||||
await archiveEra(tx, edit, archivedEntries, {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
input.asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// Flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
await archiveEra(tx, edit, [], { schedule_revision_rows: 0 });
|
||||
}
|
||||
await tombstoneOpenSlots(tx, edit);
|
||||
await tx.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Intake tracking switched without a schedule replace (#1033). Close the
|
||||
* current era at this instant: switching OFF archives the schedule that was
|
||||
* in force (the tracked stretch keeps its expected doses); switching back ON
|
||||
* archives the untracked stretch with an empty payload, so compliance never
|
||||
* counts it as missed and the live schedule, the projector and the reminder
|
||||
* worker all resume from now.
|
||||
*/
|
||||
async function archiveTrackingSwitch(
|
||||
tx: Prisma.TransactionClient,
|
||||
edit: MedicationEditWrite,
|
||||
): Promise<void> {
|
||||
const { id, wasTracked, willTrack } = edit;
|
||||
const liveRows = await tx.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
if (liveRows.length > 0) {
|
||||
const archivedEntries = wasTracked ? liveRows.map(revisionEntryOf) : [];
|
||||
await archiveEra(tx, edit, archivedEntries, {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
track_intake: willTrack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The medication row's update, field-by-field from the parsed body. */
|
||||
function medicationUpdateData(edit: MedicationEditWrite) {
|
||||
const { existing, input, normalisedSchedules } = edit;
|
||||
const {
|
||||
name,
|
||||
dose,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
reorderLeadDays,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
autoMarkTaken,
|
||||
} = input;
|
||||
return {
|
||||
...(name !== undefined && { name }),
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// Units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// Reorder lead override. Field-by-field; `null` is a
|
||||
// valid explicit value (clears the override → user-level default).
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// Injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
// (deactivate tracking / clear the per-med restriction).
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// iOS reminder flags, field-by-field.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// Optional drug-classification codes (ATC / RxNorm).
|
||||
// Field-by-field; `null` is a valid explicit value (clears the code).
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// Scheduling primitives — pass-through when supplied.
|
||||
// `startsOn` / `endsOn` are not written here (#1024): the
|
||||
// course writer below writes them together with the course they
|
||||
// project, in one transaction, so the row and its courses cannot
|
||||
// disagree if one of the two writes fails.
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// As-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// Intake tracking, field-by-field (#1033).
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
// Auto-mark-taken, field-by-field. Only an off→on flip
|
||||
// stamps `autoMarkTakenSince`, so re-saving the form with the
|
||||
// option already on never moves the floor; switching it off
|
||||
// clears the stamp.
|
||||
...(autoMarkTaken !== undefined &&
|
||||
autoMarkTaken !== existing.autoMarkTaken && {
|
||||
autoMarkTaken,
|
||||
autoMarkTakenSince: autoMarkTaken ? new Date() : null,
|
||||
}),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable pause intervals. The `pausedAt` column is a single live marker that
|
||||
* resume clears, so the paused window is irrecoverable once resumed and every
|
||||
* paused day's slot collapses to "missed" in the compliance denominator.
|
||||
* Mirror the live transition onto an additive era record the rate paths read:
|
||||
* a pause opens an era (resumedAt null), a resume closes the latest open era.
|
||||
* `userId` comes from the authenticated session; the data object is built
|
||||
* field-by-field.
|
||||
*/
|
||||
async function recordPauseTransition(
|
||||
tx: Prisma.TransactionClient,
|
||||
edit: MedicationEditWrite,
|
||||
): Promise<void> {
|
||||
const { user, id, existing } = edit;
|
||||
const { active } = edit.input;
|
||||
if (active === false && existing.active) {
|
||||
await tx.medicationPauseEra.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
userId: user.id,
|
||||
pausedAt: new Date(),
|
||||
resumedAt: null,
|
||||
},
|
||||
});
|
||||
} else if (active === true && !existing.active) {
|
||||
await tx.medicationPauseEra.updateMany({
|
||||
where: { medicationId: id, userId: user.id, resumedAt: null },
|
||||
data: { resumedAt: new Date() },
|
||||
});
|
||||
// Made active again: the auto-take pass starts from
|
||||
// now, never filling the inactive days as taken.
|
||||
await moveAutoTakeFloorToNow(tx, id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transaction. Throws `CourseWriteError` when a concurrent course write
|
||||
* landed between the preflight check and the course write; nothing is then
|
||||
* written.
|
||||
*/
|
||||
export async function writeMedicationEdit(edit: MedicationEditWrite) {
|
||||
const { user, id, input, normalisedSchedules, trackingChanged, willTrack } =
|
||||
edit;
|
||||
const { schedules } = input;
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
await replaceSchedules(tx, edit, normalisedSchedules);
|
||||
} else if (trackingChanged) {
|
||||
await archiveTrackingSwitch(tx, edit);
|
||||
}
|
||||
|
||||
// Switching intake tracking off leaves nothing due (#1033):
|
||||
// the open placeholders of today and later go the way a schedule
|
||||
// replace sends them (a replace in the same request already did it).
|
||||
if (trackingChanged && !willTrack && !schedules) {
|
||||
await tombstoneOpenSlots(tx, edit);
|
||||
}
|
||||
|
||||
const baseUpdateData = medicationUpdateData(edit);
|
||||
|
||||
const updated = await tx.medication.update({
|
||||
where: { id },
|
||||
data: { ...baseUpdateData, ...edit.pausedAtPatch },
|
||||
include: { schedules: true },
|
||||
});
|
||||
|
||||
await recordPauseTransition(tx, edit);
|
||||
|
||||
// Write the window onto the latest course and
|
||||
// project it back, then serve the projected row (#1024).
|
||||
if (edit.touchesWindow) {
|
||||
const projected = await setCurrentWindow(
|
||||
{
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: edit.courseTz,
|
||||
...edit.nextWindow,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return { ...updated, ...projected };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -244,12 +244,68 @@ 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",
|
||||
],
|
||||
"app/api/medications/intake/route.ts": [
|
||||
"lib/medications/update-intake-status/admission.ts",
|
||||
"lib/medications/update-intake-status/update-intake-status.ts",
|
||||
],
|
||||
};
|
||||
|
||||
function handlerAuditActions(file: string): string[] {
|
||||
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