feat(medications): attribute the intake write path by dose-window band

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 18:07:47 +02:00
1 parent 7b7253b989
commit c2453d6b8c
2 files changed
+111 -39

No files matched your search

+64 -27
View File
@@ -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) {
+47 -12
View File
@@ -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