feat(medications): carry manufacturer and printed strength on an inventory container

The native pen list renders these two as its headline and subhead. The
server held neither, so a container registered on the web could not be
shown there without fabricating an empty card, and the client kept its
own copy locally with no back-fill possible.

Both columns are additive and nullable (migration 0255). Every existing
row is truthfully NULL — the values were never collected, and a plain
supply row such as a blister pack or a bottle has no use for either.

Strength stays free text rather than a parsed number + unit: pens state
it per dose, per ml, or per cartridge depending on the product, and
forcing a numeric split would lose which of the three was printed.

On PATCH an omitted field leaves the stored value untouched, the same
rule notes already followed, so a client that does not know the fields
cannot blank what another populated; an explicit null still clears. The
web add-container form collects both for a PEN, so the columns do not
ship empty forever.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:28:48 +02:00
1 parent 4ddc08f784
commit 4c3cc84daf
18 files changed
+391 -28

No files matched your search

@@ -183,3 +183,64 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc
expect(res.status).toBe(422);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => {
it("updates manufacturer + doseStrength when supplied", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(
patchReq({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBe("Example Pharma");
expect(arg.data.doseStrength).toBe("5 mg/0.5 ml");
});
it("leaves both untouched when the request omits them", async () => {
// Absent must mean "I have nothing to say", never "blank it" — a client
// that does not know the fields must not wipe what another populated.
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect("manufacturer" in arg.data).toBe(false);
expect("doseStrength" in arg.data).toBe(false);
});
it("clears a value on an explicit null", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBeNull();
expect("doseStrength" in arg.data).toBe(false);
});
});
@@ -79,6 +79,8 @@ export const PATCH = apiHandler(
markAsUsedUp,
printedExpiry,
unitsRemaining,
manufacturer,
doseStrength,
notes,
} = parsed.data;
@@ -151,6 +153,16 @@ export const PATCH = apiHandler(
? {}
: { notesEncrypted: encryptNote(notes), notes: null };
// Carton labelling follows the same absent-means-untouched rule as
// `notes`: an omitted field must not blank a value the other client
// populated. Built field by field, never spread from the parsed body.
const labellingData: {
manufacturer?: string | null;
doseStrength?: string | null;
} = {};
if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
@@ -159,6 +171,7 @@ export const PATCH = apiHandler(
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
...labellingData,
...notesData,
},
});
@@ -471,3 +471,62 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("POST /api/medications/[id]/inventory — carton labelling", () => {
it("persists manufacturer + doseStrength when the client sends them", async () => {
// The native pen list uses these two as its headline and subhead. With
// nowhere on the server to hold them, a container registered on the web
// could not be rendered there at all.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
containerType: "PEN",
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
});
});
it("stores null for both when the client omits them", async () => {
// A plain supply row (blister, bottle) carries no carton labelling, and
// every row that predates the columns is truthfully null.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 30,
containerType: "BLISTER",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: null,
doseStrength: null,
}),
});
});
});
@@ -121,8 +121,15 @@ export const POST = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } =
parsed.data;
const {
unitsTotal,
containerType,
printedExpiry,
purchasedAt,
manufacturer,
doseStrength,
notes,
} = parsed.data;
const created = await prisma.medicationInventoryItem.create({
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
@@ -135,6 +142,8 @@ export const POST = apiHandler(
containerType: containerType ?? "OTHER",
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
manufacturer: manufacturer ?? null,
doseStrength: doseStrength ?? null,
notes: notes ?? null,
}),
});
@@ -492,6 +492,11 @@ export function AddInventoryDialog({
const [containerType, setContainerType] =
useState<ContainerType>(initialContainerType);
const [expiry, setExpiry] = useState("");
// Carton labelling. Only offered for a PEN: the native pen list is the
// one surface that renders them, and asking for a maker + strength on a
// blister pack would be noise on the far commoner path.
const [manufacturer, setManufacturer] = useState("");
const [doseStrength, setDoseStrength] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
@@ -513,6 +518,10 @@ export function AddInventoryDialog({
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
// Trimmed to null so a field the user opened and left blank stores
// absence rather than an empty string.
manufacturer: manufacturer.trim() || null,
doseStrength: doseStrength.trim() || null,
});
await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.addSuccess"));
@@ -664,6 +673,41 @@ export function AddInventoryDialog({
onChange={setExpiry}
/>
</div>
{containerType === "PEN" && (
<>
<div className="space-y-2">
<label
htmlFor="inventory-add-manufacturer"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addManufacturerLabel")}
</label>
<Input
id="inventory-add-manufacturer"
value={manufacturer}
maxLength={120}
onChange={(e) => setManufacturer(e.target.value)}
/>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-dose-strength"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addDoseStrengthLabel")}
</label>
<Input
id="inventory-add-dose-strength"
value={doseStrength}
maxLength={60}
onChange={(e) => setDoseStrength(e.target.value)}
/>
<p className="text-muted-foreground text-sm">
{t("medications.detail.bestand.addDoseStrengthHint")}
</p>
</div>
</>
)}
</form>
</ResponsiveSheet>
);
@@ -80,6 +80,8 @@ describe("buildCreateInventoryInput", () => {
containerType: "PEN",
printedExpiry: printed,
purchasedAt: null,
manufacturer: null,
doseStrength: null,
notes: null,
});
expect(input).toMatchObject({
@@ -100,6 +102,8 @@ describe("buildCreateInventoryInput", () => {
containerType: "OTHER",
printedExpiry: null,
purchasedAt: null,
manufacturer: null,
doseStrength: null,
notes: null,
});
expect(input.expiresAt).toBeNull();
@@ -157,3 +161,40 @@ describe("serializeInventoryItem (iOS#31)", () => {
expect(out).toMatchObject({ id: "i5", state: "ACTIVE" });
});
});
describe("buildCreateInventoryInput — carton labelling", () => {
it("carries manufacturer + doseStrength onto the create input", () => {
// The native pen list renders these as its headline and subhead; a
// container created on the web carried neither, so the row could not be
// shown there without fabricating an empty card.
const input = buildCreateInventoryInput({
userId: "u",
medicationId: "m",
unitsTotal: 4,
containerType: "PEN",
printedExpiry: null,
purchasedAt: null,
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
notes: null,
});
expect(input.manufacturer).toBe("Example Pharma");
expect(input.doseStrength).toBe("5 mg/0.5 ml");
});
it("leaves both null for a plain supply container", () => {
const input = buildCreateInventoryInput({
userId: "u",
medicationId: "m",
unitsTotal: 30,
containerType: "BLISTER",
printedExpiry: null,
purchasedAt: null,
manufacturer: null,
doseStrength: null,
notes: null,
});
expect(input.manufacturer).toBeNull();
expect(input.doseStrength).toBeNull();
});
});
+4
View File
@@ -144,6 +144,8 @@ export function buildCreateInventoryInput(input: {
containerType: MedicationContainerType;
printedExpiry: Date | null;
purchasedAt: Date | null;
manufacturer: string | null;
doseStrength: string | null;
notes: string | null;
}) {
return {
@@ -157,6 +159,8 @@ export function buildCreateInventoryInput(input: {
printedExpiry: input.printedExpiry,
purchasedAt: input.purchasedAt,
expiresAt: computeExpiresAt(null, input.printedExpiry),
manufacturer: input.manufacturer,
doseStrength: input.doseStrength,
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(input.notes),
notes: null,
@@ -314,6 +314,18 @@ export const medicationInventoryItemResource = z
),
printedExpiry: z.iso.datetime({ offset: true }).nullable(),
purchasedAt: z.iso.datetime({ offset: true }).nullable(),
manufacturer: z
.string()
.nullable()
.describe(
"Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the field existed, and for plain supply rows that never carried one.",
),
doseStrength: z
.string()
.nullable()
.describe(
'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens state strength per dose, per ml, or per cartridge. NULL when unknown.',
),
notes: z.string().nullable(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
@@ -54,6 +54,24 @@ export const createInventoryItemSchema = z
.transform((s) => new Date(s))
.nullable()
.optional(),
/** Carton labelling — free text, see the schema comment on the model
* for why neither is parsed into a structured value. */
manufacturer: z
.string()
.max(120)
.nullable()
.optional()
.describe(
"Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.",
),
doseStrength: z
.string()
.max(60)
.nullable()
.optional()
.describe(
'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.',
),
notes: z.string().max(200).nullable().optional(),
})
.meta({
@@ -99,6 +117,24 @@ export const updateInventoryItemSchema = z
.describe(
"Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
),
/** Carton labelling — free text, see the schema comment on the model
* for why neither is parsed into a structured value. */
manufacturer: z
.string()
.max(120)
.nullable()
.optional()
.describe(
"Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.",
),
doseStrength: z
.string()
.max(60)
.nullable()
.optional()
.describe(
'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.',
),
notes: z.string().max(200).nullable().optional(),
})
.meta({