feat: add inhaler and injection package types

Closes #558

- add inhaler and injection as supported medication package types
- align refill, planner, dashboard, report, export, and notification wording for the new discrete package types
- include the validated CI repair for formatting and dashboard label parity
This commit is contained in:
Daniel Volz
2026-05-11 21:29:59 +02:00
committed by GitHub
parent 26e9b39f47
commit c5c75f65e4
32 changed files with 584 additions and 141 deletions
+8 -2
View File
@@ -181,6 +181,8 @@ type TranslationKeys = {
common: {
pill: string;
pills: string;
puffs: string;
injections: string;
units: string;
ml: string;
blister: string;
@@ -211,7 +213,7 @@ const translations: Record<Language, TranslationKeys> = {
descriptionLow: "The following medications are running low and should be reordered soon:",
tableHeaders: {
medication: "Medication",
pills: "Pills",
pills: "Available",
days: "Days",
runsOut: "Runs Out",
},
@@ -305,6 +307,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "pill",
pills: "pills",
puffs: "puffs",
injections: "injections",
units: "units",
ml: "ml",
blister: "blister",
@@ -333,7 +337,7 @@ const translations: Record<Language, TranslationKeys> = {
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
tableHeaders: {
medication: "Medikament",
pills: "Tabletten",
pills: "Verfuegbar",
days: "Tage",
runsOut: "Aufgebraucht",
},
@@ -430,6 +434,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "Tablette",
pills: "Tabletten",
puffs: "Hübe",
injections: "Injektionen",
units: "Einheiten",
ml: "ml",
blister: "Blister",
+6 -2
View File
@@ -62,7 +62,7 @@ const medicationExportSchema = z.object({
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
medicationStartDate: z.string().nullable().optional(),
medicationEndDate: z.string().nullable().optional(),
@@ -560,7 +560,11 @@ export async function exportRoutes(app: FastifyInstance) {
const packageType = normalizePackageType(medication?.packageType);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const quantityAdded =
packageType === "bottle" || packageType === "tube" || packageType === "liquid_container"
packageType === "bottle" ||
packageType === "inhaler" ||
packageType === "injection" ||
packageType === "tube" ||
packageType === "liquid_container"
? (refill.loosePillsAdded ?? 0)
: (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
+5 -2
View File
@@ -70,7 +70,10 @@ const strengthOptionSchema = {
label: { type: "string" },
pillWeightMg: { type: "number", nullable: true },
doseUnit: {
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
anyOf: [
{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
{ type: "null" },
],
},
},
} as const;
@@ -80,7 +83,7 @@ const packageOptionSchema = {
properties: {
label: { type: "string" },
description: { type: "string" },
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] },
packCount: { type: "integer", minimum: 1 },
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
+5 -4
View File
@@ -24,6 +24,7 @@ import {
} from "../utils/openapi-route-standards.js";
import {
isAmountBasedPackageType,
isDiscreteCountPackageType,
isLiquidContainerPackageType,
isTubePackageType,
normalizePackageType,
@@ -67,7 +68,7 @@ const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister");
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
const pillFormSchema = z.enum(["capsule", "tablet"]);
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg");
const medicationStartDateSchema = z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
.optional();
@@ -264,7 +265,7 @@ const medicationBodyOpenApiSchema = {
totalPills: { type: ["integer", "null"], minimum: 1 },
looseTablets: { type: "integer", minimum: 0 },
pillWeightMg: { type: ["number", "null"], minimum: 0 },
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] },
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
medicationStartDate: {
anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }],
},
@@ -1201,7 +1202,7 @@ export async function medicationRoutes(app: FastifyInstance) {
const packageType = normalizePackageType(existing.packageType);
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
const allowsBottleCapacityUpdate = packageType === "bottle";
const allowsDiscreteCapacityUpdate = isDiscreteCountPackageType(packageType);
if (allowsAmountBaseUpdate) {
const normalizedAmountBase = looseTablets ?? totalPills;
if (normalizedAmountBase !== undefined) {
@@ -1210,7 +1211,7 @@ export async function medicationRoutes(app: FastifyInstance) {
}
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
}
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
if (allowsDiscreteCapacityUpdate && totalPills !== undefined) {
updateFields.totalPills = totalPills;
}
if (packCount !== undefined) updateFields.packCount = packCount;
+9 -8
View File
@@ -19,7 +19,7 @@ import {
type StockReminderItem as SharedStockReminderItem,
} from "../services/notifications/builders.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { escapeHtml, formatPlannerQuantity, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import {
@@ -54,6 +54,7 @@ type SendEmailBody = {
type LowStockItem = {
name: string;
medsLeft: number;
packageType?: string;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
@@ -567,11 +568,10 @@ ${getFooterPlain(language)}`;
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
.filter(([name]) => name.length > 0)
);
const filteredLowStock = lowStock.filter((item) => {
const filteredLowStock = lowStock.flatMap((item) => {
const packageType = activeMedicationByName.get(item.name);
if (!packageType) return false;
if (isTubePackageType(packageType)) return false;
return true;
if (!packageType || isTubePackageType(packageType)) return [];
return [{ ...item, packageType }];
});
if (filteredLowStock.length === 0) {
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
@@ -644,7 +644,7 @@ ${getFooterPlain(language)}`;
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
`${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
@@ -653,7 +653,7 @@ ${getFooterPlain(language)}`;
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowStockMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
`${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
@@ -734,12 +734,13 @@ ${getFooterPlain(language)}`;
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
const safeName = escapeHtml(row.name);
const safeMedsLeft = Number(row.medsLeft) || 0;
const safeQuantity = escapeHtml(formatPlannerQuantity(row.packageType, safeMedsLeft, tr));
const safeDaysLeft = Number(row.daysLeft) || 0;
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeMedsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeQuantity}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now}</strong>` : safeDepletionDate}</td>
</tr>`;
+19 -20
View File
@@ -12,7 +12,12 @@ import {
idParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
isAmountBasedPackageType,
isDiscreteCountPackageType,
isPackageAmountPackageType,
normalizePackageType,
} from "../utils/package-profiles.js";
const refillSchema = z
.object({
@@ -143,10 +148,10 @@ export async function refillRoutes(app: FastifyInstance) {
const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle;
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const isPackageAmountPackage = isPackageAmountPackageType(packageType);
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
@@ -161,18 +166,11 @@ export async function refillRoutes(app: FastifyInstance) {
const requestedPackAdds = Math.max(0, packsAdded);
const requestedLooseAdds = Math.max(0, loosePillsAdded);
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
let effectivePacksAdded = requestedPackAdds;
if (isBottle) {
if (isDiscreteCountPackage) {
effectivePacksAdded = 0;
} else if (isCountBasedAmountPackage) {
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
}
const effectiveLoosePillsAdded = isCountBasedAmountPackage
? effectivePacksAdded * amountPerPackage
: requestedAmountAdds;
const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
@@ -189,7 +187,7 @@ export async function refillRoutes(app: FastifyInstance) {
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
if (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
}
@@ -207,10 +205,11 @@ export async function refillRoutes(app: FastifyInstance) {
let newStockAdjustment = med.stockAdjustment ?? 0;
let newTotalAmount = med.totalPills ?? med.looseTablets;
if (isBottle) {
if (isDiscreteCountPackage) {
newLooseTablets = targetCurrentStock;
newTotalAmount = Math.max(newTotalAmount, targetCurrentStock);
newStockAdjustment = 0;
} else if (isCountBasedAmountPackage) {
} else if (isPackageAmountPackage) {
newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
newLooseTablets = targetCurrentStock;
newTotalAmount = targetCurrentStock;
@@ -222,7 +221,7 @@ export async function refillRoutes(app: FastifyInstance) {
let consumedRefills = 0;
if (usePrescription) {
consumedRefills = isBottle ? 1 : effectivePacksAdded;
consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
@@ -246,7 +245,7 @@ export async function refillRoutes(app: FastifyInstance) {
updatedAt: refillBaselineAt,
};
if (isCountBasedAmountPackage) {
if (isPackageAmountPackage) {
updatePayload.totalPills = newTotalAmount;
updatePayload.packageAmountValue = amountPerPackage;
}
@@ -329,9 +328,9 @@ export async function refillRoutes(app: FastifyInstance) {
.orderBy(desc(refillHistory.refillDate));
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
const isAmountBased = isAmountBasedPackageType(packageType);
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
return refills.map((r) => ({
id: r.id,
@@ -1,8 +1,10 @@
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
import { formatPlannerQuantity } from "../planner-service.js";
export type StockReminderItem = {
name: string;
medsLeft: number;
packageType?: string;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
@@ -47,7 +49,7 @@ export function buildStockReminderPushNotification(
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
`${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
)
);
}
@@ -56,7 +58,7 @@ export function buildStockReminderPushNotification(
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
`${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
)
);
}
+11 -1
View File
@@ -48,10 +48,20 @@ export function isContainerPackage(packageType?: string): boolean {
export function getPlannerUnit(
packageType: string | undefined,
tr: { common: { units: string; ml: string; pills: string } }
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
): string {
const unitKind = getPlannerUnitKind(packageType);
if (unitKind === "units") return tr.common.units;
if (unitKind === "ml") return tr.common.ml;
if (unitKind === "puffs") return tr.common.puffs ?? tr.common.pills;
if (unitKind === "injections") return tr.common.injections ?? tr.common.pills;
return tr.common.pills;
}
export function formatPlannerQuantity(
packageType: string | undefined,
count: number,
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
): string {
return `${count} ${getPlannerUnit(packageType, tr)}`;
}
+6 -2
View File
@@ -38,6 +38,7 @@ import {
} from "./notifications/builders.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
import { loadReminderState, saveReminderState, updateUserReminderSentTime } from "./notifications/state.js";
import { formatPlannerQuantity } from "./planner-service.js";
export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
@@ -108,6 +109,7 @@ function releaseReminderSendLock(lockFilePath: string | null): void {
type LowStockItem = {
name: string;
medsLeft: number;
packageType?: string;
daysLeft: number | null;
depletionDate: string | null;
isCritical: boolean;
@@ -309,6 +311,7 @@ async function getMedicationsNeedingReminder(
lowStock.push({
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
medsLeft: currentPills,
packageType,
daysLeft,
depletionDate,
isCritical,
@@ -432,10 +435,11 @@ async function sendReminderEmail(
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
const quantityText = formatPlannerQuantity(row.packageType, row.medsLeft, tr);
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${quantityText}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now ?? "-"}</strong>` : (row.depletionDate ?? "-")}</td>
</tr>`;
@@ -479,7 +483,7 @@ async function sendReminderEmail(
${tr.stockReminder.description}
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
${lowStock.map((r) => `${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
---
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
@@ -62,13 +62,17 @@ describe("planner-service decomposition regression", () => {
});
it("maps package type to expected planner units after service extraction", () => {
const tr = { common: { units: "units", ml: "ml", pills: "pills" } };
const tr = { common: { units: "units", ml: "ml", pills: "pills", puffs: "puffs", injections: "injections" } };
expect(isContainerPackage("bottle")).toBe(true);
expect(isContainerPackage("inhaler")).toBe(true);
expect(isContainerPackage("injection")).toBe(true);
expect(isContainerPackage("blister")).toBe(false);
expect(getPlannerUnit("tube", tr)).toBe("units");
expect(getPlannerUnit("liquid_container", tr)).toBe("ml");
expect(getPlannerUnit("bottle", tr)).toBe("pills");
expect(getPlannerUnit("inhaler", tr)).toBe("puffs");
expect(getPlannerUnit("injection", tr)).toBe("injections");
expect(getPlannerUnit("blister", tr)).toBe("pills");
});
});
+96 -13
View File
@@ -1962,7 +1962,7 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 5, quantityAdded: 5 },
});
expect(refillResponse.statusCode).toBe(200);
@@ -2336,10 +2336,9 @@ describe("E2E Tests with Real Routes", () => {
expect(med.stockAdjustment).toBe(0);
});
it("should persist bottle zero reset with packCount 0 and zero totals", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
it.each([
{
label: "bottle",
payload: {
name: "Bottle Zero Reset Med",
packageType: "bottle",
@@ -2350,6 +2349,40 @@ describe("E2E Tests with Real Routes", () => {
looseTablets: 20,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
},
{
label: "inhaler",
payload: {
name: "Inhaler Zero Reset Med",
packageType: "inhaler",
doseUnit: "puffs",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 200,
looseTablets: 40,
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
},
{
label: "injection",
payload: {
name: "Injection Zero Reset Med",
packageType: "injection",
doseUnit: "injections",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 12,
looseTablets: 4,
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
},
},
])("should persist $label zero reset with packCount 0 and zero totals", async ({ payload }) => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
@@ -2495,7 +2528,7 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 150, quantityAdded: 150 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
@@ -3125,6 +3158,39 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
const discreteContainerMedications = [
{
label: "inhaler",
payload: {
name: "Asthma Inhaler",
packageType: "inhaler",
doseUnit: "puffs",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 200,
looseTablets: 200,
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
expectedDoseUnit: "puffs",
},
{
label: "injection",
payload: {
name: "B12 Injection",
packageType: "injection",
doseUnit: "injections",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 12,
looseTablets: 12,
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
},
expectedDoseUnit: "injections",
},
] as const;
async function expectRefillInvariants({
medId,
refillData,
@@ -3225,6 +3291,23 @@ describe("E2E Tests with Real Routes", () => {
expect(data.looseTablets).toBe(180);
});
it.each(discreteContainerMedications)("should create and return $label type medication", async ({
payload,
expectedDoseUnit,
}) => {
const response = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.packageType).toBe(payload.packageType);
expect(data.doseUnit).toBe(expectedDoseUnit);
expect(data.looseTablets).toBe(payload.looseTablets);
});
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
await app.inject({
method: "POST",
@@ -3407,7 +3490,7 @@ describe("E2E Tests with Real Routes", () => {
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
refillPayload: { packsAdded: 1, loosePillsAdded: 100, quantityAdded: 100 },
expectedVisibleStockBeforeRefill: 10,
expectedQuantityAdded: 100,
expectedResponsePacksAdded: 1,
@@ -3522,7 +3605,7 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 80, quantityAdded: 80 },
});
expect(refillResponse.statusCode).toBe(200);
@@ -3602,7 +3685,7 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180 },
});
expect(refillResponse.statusCode).toBe(200);
@@ -3649,7 +3732,7 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 5, loosePillsAdded: 0 },
payload: { packsAdded: 5, loosePillsAdded: 750, quantityAdded: 750 },
});
expect(refillResponse.statusCode).toBe(200);
@@ -3689,7 +3772,7 @@ describe("E2E Tests with Real Routes", () => {
prescriptionRemainingRefills: 2,
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
refillPayload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180, usePrescription: true },
expectedVisibleStockBeforeRefill: 180,
expectedPacksAdded: 1,
expectedLooseAdded: 180,
@@ -3706,7 +3789,7 @@ describe("E2E Tests with Real Routes", () => {
prescriptionRemainingRefills: 3,
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
refillPayload: { packsAdded: 2, loosePillsAdded: 80, quantityAdded: 80, usePrescription: true },
expectedVisibleStockBeforeRefill: 80,
expectedPacksAdded: 2,
expectedLooseAdded: 80,
@@ -3786,7 +3869,7 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 40, quantityAdded: 40 },
});
expect(refillResponse.statusCode).toBe(200);
+15 -4
View File
@@ -1,4 +1,4 @@
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] as const;
export type PackageType = (typeof PACKAGE_TYPES)[number];
@@ -19,14 +19,25 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole
return normalizePackageType(packageType) === "liquid_container";
}
export function isAmountBasedPackageType(packageType?: string | null): boolean {
export function isPackageAmountPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
return normalized === "tube" || normalized === "liquid_container";
}
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
export function isDiscreteCountPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "bottle" || normalized === "inhaler" || normalized === "injection";
}
export function isAmountBasedPackageType(packageType?: string | null): boolean {
return isPackageAmountPackageType(packageType) || isDiscreteCountPackageType(packageType);
}
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" | "puffs" | "injections" {
const normalized = normalizePackageType(packageType);
if (normalized === "tube") return "units";
if (normalized === "liquid_container") return "ml";
if (normalized === "inhaler") return "puffs";
if (normalized === "injection") return "injections";
return "pills";
}