mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
7b7253b989
commit
c2453d6b8c
2 files changed
+111
-39
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user