diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 01ac66aeb..6dd257d6e 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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, }); }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index b4a82bafc..4a90d9936 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 & { id: string; unitsPerDose: unknown }, + medication: Record & { + id: string; + unitsPerDose: unknown; + schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>; + }, ): Promise { 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, diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 2e22cba9d..cbc190833 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -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 diff --git a/src/lib/medications/schedule-units-dto.ts b/src/lib/medications/schedule-units-dto.ts new file mode 100644 index 000000000..6aa8b694c --- /dev/null +++ b/src/lib/medications/schedule-units-dto.ts @@ -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( + schedules: readonly T[], +): Array & { unitsPerDose: number | null }> { + return schedules.map((schedule) => ({ + ...schedule, + unitsPerDose: + schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose), + })); +} diff --git a/src/lib/validations/medication/schedule.ts b/src/lib/validations/medication/schedule.ts index 9ee4ef4b4..5b464a3a2 100644 --- a/src/lib/validations/medication/schedule.ts +++ b/src/lib/validations/medication/schedule.ts @@ -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()