From c2453d6b8cdeae5e22902f78f699b96711d047b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:07:47 +0200 Subject: [PATCH] feat(medications): attribute the intake write path by dose-window band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-medication intake POST resolves a taken dose's slot through the band attributor instead of the ±6h snap: a take inside a slot's configurable dose window snaps to that slot (canonical upsert), a take outside every window records as a standalone ad-hoc row, and a PRN medication always inserts standalone. A skip still binds to its slot by the canonical scheduledFor snap. Add an optional forceSlotInstant to the intake body so the UI's late-take "diesem Slot zuordnen?" nudge can pin an off-window take onto a chosen slot; the server validates the instant is a real slot of the medication on its day and rejects a non-slot with 422. The dose-safety future-slot guard stays. --- src/app/api/medications/[id]/intake/route.ts | 91 ++++++++++++++------ src/lib/validations/medication.ts | 59 ++++++++++--- 2 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index dc4d03b66..1373fe569 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -24,6 +24,8 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, resolveSlotInstantForWrite, } from "@/lib/medications/scheduling/slot-upsert"; import { NextRequest } from "next/server"; @@ -79,8 +81,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { return returnAllZodIssues(parsed.error, 422); } - const { scheduledFor, takenAt, skipped, idempotencyKey, injectionSite } = - parsed.data; + const { + scheduledFor, + takenAt, + skipped, + idempotencyKey, + injectionSite, + forceSlotInstant, + } = parsed.data; // v1.8.5 — resolve + server-validate the optional injection site. Load // the medication's delivery form + tracking opt-in + per-medication @@ -137,32 +145,61 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { const isExplicitTaken = !skipped; const isExplicitSkip = skipped === true; - // v1.8.2 — source-agnostic slot snap. A twice-daily med carries a - // pending REMINDER row the projector/worker minted at the canonical - // `localHmAsUtc` slot instant. Without this, a manual "Genommen" write - // (source WEB) inserted a SECOND row for the slot because the unique - // key includes `source` and the iOS-vs-server `scheduledFor` can drift - // by a minute — inflating compliance to 100% and suppressing the - // "take now" prompt for a dose the user hadn't taken. Snap the write - // to the canonical slot and update the existing row in place. + // 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). // - // Resolved BEFORE the idempotency/dedup window so a scheduled dose - // routes through the slot upsert (which is itself the dedup): the - // legacy 60-second window would otherwise short-circuit by returning - // the slot's pending REMINDER row WITHOUT applying the user's takenAt. - const canonicalSlot = await resolveSlotInstantForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - incoming: incomingScheduledFor, - // Only a client-supplied `scheduledFor` names a real slot. A - // `takenAt`-only / defaulted-now write must not snap across the wide - // ±halfGap window onto a far slot (phantom morning dose). - instantIsExplicit: scheduledFor !== undefined, - // Dose-safety: a taken write (any non-skip on this route) must never - // snap forward onto a future slot — see resolve-slot-instant.ts. - isTakenWrite: isExplicitTaken, - }); + // - 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; + if (skipped) { + canonicalSlot = await resolveSlotInstantForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + incoming: incomingScheduledFor, + instantIsExplicit: scheduledFor !== undefined, + isTakenWrite: false, + }); + } else if (forceSlotInstant !== undefined) { + 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 apiError( + "forceSlotInstant is not a scheduled slot of this medication", + 422, + { errorCode: "medications.intake.force_slot.invalid" }, + ); + } + } 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; + } // Idempotency check (explicit key or server-side dedup window) if (idempotencyKey) { diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 3f560b1f0..a4d6d0640 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -550,6 +550,21 @@ export const intakeSchema = z .describe( "Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site.", ), + /** + * v1.15.18 — late-take "attribute anyway" pin. An off-window take that + * band attribution would otherwise orphan to an ad-hoc row can be pinned + * onto a chosen scheduled slot via the UI's "diesem Slot zuordnen?" nudge. + * The instant MUST be a real slot of this medication on its day (the + * server validates it against the band anchors); an arbitrary instant is + * rejected with 422. Absent → default band attribution. + */ + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution.", + ), }) .meta({ id: "MedicationIntakeRequest", @@ -594,18 +609,38 @@ export const listIntakeEventsSchema = z.object({ .default("all"), }); -export const updateIntakeEventSchema = z.object({ - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - skipped: z.boolean().optional(), - scheduledFor: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), -}); +export const updateIntakeEventSchema = z + .object({ + takenAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + skipped: z.boolean().optional(), + scheduledFor: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + /** + * v1.15.18 — late-take "attribute anyway" pin on the edit path. When the + * edited `takenAt` lands outside every window the UI can offer to pin the + * take onto a chosen slot; the server validates the instant is a real + * scheduled slot (422 otherwise). Absent → the edit re-runs band + * attribution on the new `takenAt`. + */ + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Absent re-runs the default window-band attribution on the edited `takenAt`.", + ), + }) + .meta({ + id: "UpdateMedicationIntakeEventRequest", + description: + "Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly.", + }); /** * v1.5.5 — bulk-delete request body. The detail-page intake-history