feat(medications): accept and return per-schedule units-per-dose

Add schedules[].unitsPerDose to the create/update contract with the
same whole-number-or-curated-fraction validation as the medication
level, built into the Prisma data field-by-field on both the create
and the schedule-replace path. Unwrap the nullable Decimal to a JSON
number at every medication read/write response through one shared
serializer so the wire shape cannot drift, keeping NULL as NULL to
mean inherit-the-medication-level.
This commit is contained in:
Marc-André Bombeck committed 2026-08-10 07:58:48 +02:00
1 parent 820dde2365
commit 88123c53da
5 files changed
+67 -2

No files matched your search

+9
View File
@@ -33,6 +33,7 @@ import {
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { NextRequest } from "next/server";
@@ -179,6 +180,7 @@ export const GET = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category,
nextDueAt: display ? display.at.toISOString() : null,
nextDueOverdue: display?.overdue ?? false,
@@ -404,6 +406,12 @@ export const PUT = apiHandler(
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,
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
@@ -828,6 +836,7 @@ export const PUT = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category: normalizedCategory,
});
},
+17 -1
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -21,6 +22,7 @@ import {
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { readMedicationsListCached } from "@/lib/medications/list-read";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { NextRequest } from "next/server";
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
@@ -66,7 +68,11 @@ export const GET = apiHandler(async () => {
* nothing was written.
*/
async function respondWithExistingMirror(
medication: Record<string, unknown> & { id: string; unitsPerDose: unknown },
medication: Record<string, unknown> & {
id: string;
unitsPerDose: unknown;
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
},
): Promise<Response> {
let category = "OTHER";
try {
@@ -88,6 +94,8 @@ async function respondWithExistingMirror(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category,
});
}
@@ -311,6 +319,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
// #219 — per-schedule inventory units. Field-by-field (no mass
// assignment); absent leaves the column NULL so the consume
// hook inherits the medication-level units_per_dose.
...(s.unitsPerDose !== undefined && {
unitsPerDose: s.unitsPerDose,
}),
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
@@ -398,6 +412,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
{
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(medication.schedules),
category: normalizedCategory,
},
201,
+3
View File
@@ -15,6 +15,7 @@
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { getMedicationCategories } from "@/lib/medication-category";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
@@ -255,6 +256,8 @@ export async function buildMedicationsList(
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
// the string Prisma would otherwise serialise a Decimal to.
unitsPerDose: Number(m.unitsPerDose),
// #219 — same Decimal → number unwrap for the per-schedule column.
schedules: serializeScheduleUnitsPerDose(m.schedules),
category: categoryMap[m.id] ?? "OTHER",
// v1.32.25 — provenance echo. Surfacing the mirror source lets the
// web UI and an operator tell an externally-mirrored row (today only
+24
View File
@@ -0,0 +1,24 @@
/**
* #219 — per-schedule `unitsPerDose` wire serialisation.
*
* The column is a nullable `Decimal(10,4)`; Prisma serialises a Decimal to a
* JSON STRING, which the client would then have to coerce. The medication-level
* `unitsPerDose` is already unwrapped to a JSON number at every response point
* (`Number(medication.unitsPerDose)`), so mirror that for the per-schedule
* column: convert to a number, keep NULL as NULL (NULL means "inherit the
* medication level"). One helper so the five medication read/write responses
* cannot drift on the shape.
*/
import type { Prisma } from "@/generated/prisma/client";
type ScheduleWithUnits = { unitsPerDose: Prisma.Decimal | null };
export function serializeScheduleUnitsPerDose<T extends ScheduleWithUnits>(
schedules: readonly T[],
): Array<Omit<T, "unitsPerDose"> & { unitsPerDose: number | null }> {
return schedules.map((schedule) => ({
...schedule,
unitsPerDose:
schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose),
}));
}
+14 -1
View File
@@ -1,7 +1,13 @@
import { z } from "zod/v4";
import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence";
import { RRULE_PROPS, doseWindowEntrySchema, timeRegex } from "./base";
import {
RRULE_PROPS,
UNITS_PER_DOSE_MESSAGE,
doseWindowEntrySchema,
isSupportedUnitsPerDose,
timeRegex,
} from "./base";
export const scheduleSchema = z
.object({
@@ -29,6 +35,13 @@ export const scheduleSchema = z
.describe(
"Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.",
),
unitsPerDose: z
.number()
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
.optional()
.describe(
"Per-schedule inventory units consumed per dose (#219). A whole number 1-100 or a supported fraction (¼ / ⅓ / ½ / ⅔ / ¾) for a split pill. Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets one medication decrement a different tablet count at different times of day (a whole tablet in the morning, a half at noon).",
),
daysOfWeek: z
.array(z.number().int().min(0).max(6))
.optional()