feat: replace hardcoded package assumptions with profile abstraction (#379)

This commit is contained in:
Daniel Volz
2026-03-04 21:15:05 +01:00
committed by GitHub
parent 6672fb78c9
commit 4936929849
23 changed files with 440 additions and 289 deletions
+4 -3
View File
@@ -10,6 +10,7 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
@@ -39,7 +40,7 @@ const inventorySchema = z.object({
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister"),
packageType: z.enum(PACKAGE_TYPES).default("blister"),
packageAmountValue: z.number().int().min(0).default(0),
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
});
@@ -319,7 +320,7 @@ export async function exportRoutes(app: FastifyInstance) {
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister",
packageType: normalizePackageType(med.packageType),
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
},
@@ -595,7 +596,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: med.inventory.packageType ?? "blister",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
+24 -18
View File
@@ -14,6 +14,13 @@ import {
streamToBuffer,
writeOptimizedImageSet,
} from "../utils/image-upload.js";
import {
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
normalizePackageType,
PACKAGE_TYPES,
} from "../utils/package-profiles.js";
import {
type Intake,
normalizeIntakeUsageForStock,
@@ -75,7 +82,7 @@ const blisterSchema = z.object({
start: z.string().datetime({ local: true }),
});
const packageTypeSchema = z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister");
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");
@@ -163,7 +170,7 @@ const medicationSchema = z
.refine(
(data) => {
if (data.medicationForm === "topical") {
return data.packageType === "tube";
return isTubePackageType(data.packageType);
}
return true;
},
@@ -175,7 +182,7 @@ const medicationSchema = z
.refine(
(data) => {
if (data.medicationForm === "liquid") {
return data.packageType === "liquid_container";
return isLiquidContainerPackageType(data.packageType);
}
return true;
},
@@ -187,7 +194,7 @@ const medicationSchema = z
.refine(
(data) => {
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
return data.packageType !== "tube" && data.packageType !== "liquid_container";
return !isTubePackageType(data.packageType) && !isLiquidContainerPackageType(data.packageType);
}
return true;
},
@@ -294,7 +301,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationForm: row.medicationForm ?? "tablet",
pillForm: row.pillForm ?? null,
lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty",
packageType: row.packageType ?? "blister",
packageType: normalizePackageType(row.packageType),
packCount: row.packCount ?? 1,
blistersPerPack: row.blistersPerPack ?? 1,
pillsPerBlister: row.pillsPerBlister ?? 1,
@@ -412,7 +419,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationForm: medicationForm ?? "tablet",
pillForm: normalizedPillForm,
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
packageType: packageType ?? "blister",
packageType: normalizePackageType(packageType),
packCount,
blistersPerPack,
pillsPerBlister,
@@ -448,7 +455,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationForm: inserted.medicationForm ?? "tablet",
pillForm: inserted.pillForm ?? null,
lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty",
packageType: inserted.packageType ?? "blister",
packageType: normalizePackageType(inserted.packageType),
packCount: inserted.packCount,
blistersPerPack: inserted.blistersPerPack,
pillsPerBlister: inserted.pillsPerBlister,
@@ -583,7 +590,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationForm: medicationForm ?? "tablet",
pillForm: normalizedPillForm,
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
packageType: packageType ?? "blister",
packageType: normalizePackageType(packageType),
packCount,
blistersPerPack,
pillsPerBlister,
@@ -743,7 +750,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationForm: result[0].medicationForm ?? "tablet",
pillForm: result[0].pillForm ?? null,
lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty",
packageType: result[0].packageType ?? "blister",
packageType: normalizePackageType(result[0].packageType),
packCount: result[0].packCount,
blistersPerPack: result[0].blistersPerPack,
pillsPerBlister: result[0].pillsPerBlister,
@@ -901,8 +908,8 @@ export async function medicationRoutes(app: FastifyInstance) {
updatedAt: new Date(),
};
const packageType = existing.packageType ?? "blister";
const allowsAmountBaseUpdate = packageType === "tube" || packageType === "liquid_container";
const packageType = normalizePackageType(existing.packageType);
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
if (allowsAmountBaseUpdate) {
if (totalPills !== undefined) updateFields.totalPills = totalPills;
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
@@ -1097,14 +1104,13 @@ export async function medicationRoutes(app: FastifyInstance) {
const blistersPerPack = row.blistersPerPack ?? 1;
const looseTablets = row.looseTablets ?? 0;
const stockAdjustment = row.stockAdjustment ?? 0;
const packageType = row.packageType ?? "blister";
const packageType = normalizePackageType(row.packageType);
// For bottle type, looseTablets IS the current stock (no blister math)
const isTopical = medForm === "topical" || packageType === "tube";
const originalTotalPills =
packageType === "bottle" || packageType === "liquid_container"
? looseTablets + stockAdjustment
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
const isTopical = medForm === "topical" || isTubePackageType(packageType);
const originalTotalPills = isAmountBasedPackageType(packageType)
? looseTablets + stockAdjustment
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
@@ -1232,7 +1238,7 @@ export async function medicationRoutes(app: FastifyInstance) {
let fullBlisters: number;
let loosePills: number;
if (packageType === "bottle" || packageType === "tube" || packageType === "liquid_container") {
if (isAmountBasedPackageType(packageType)) {
// Bottle type: no blisters, everything is loose pills
fullBlisters = 0;
loosePills = availableAfterPeriod;
+12 -5
View File
@@ -15,6 +15,12 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import {
getPlannerUnitKind,
isAmountBasedPackageType,
isTubePackageType,
normalizePackageType,
} from "../utils/package-profiles.js";
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
// Escape HTML to prevent XSS in email templates
@@ -80,12 +86,13 @@ type PlannerRow = {
};
function isContainerPackage(packageType?: string): boolean {
return packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
return isAmountBasedPackageType(packageType);
}
function getPlannerUnit(packageType: string | undefined, tr: ReturnType<typeof getTranslations>): string {
if (packageType === "tube") return tr.common.units;
if (packageType === "liquid_container") return tr.common.ml;
const unitKind = getPlannerUnitKind(packageType);
if (unitKind === "units") return tr.common.units;
if (unitKind === "ml") return tr.common.ml;
return tr.common.pills;
}
@@ -481,13 +488,13 @@ ${getFooterPlain(language)}`;
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const activeMedicationByName = new Map(
activeMeds
.map((med) => [med.name || med.genericName || "", med.packageType ?? "blister"] as const)
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
.filter(([name]) => name.length > 0)
);
const filteredLowStock = lowStock.filter((item) => {
const packageType = activeMedicationByName.get(item.name);
if (!packageType) return false;
if (packageType === "tube") return false;
if (isTubePackageType(packageType)) return false;
return true;
});
if (filteredLowStock.length === 0) {
+5 -7
View File
@@ -7,6 +7,7 @@ import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -119,12 +120,9 @@ export async function shareRoutes(app: FastifyInstance) {
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
const totalPills =
(med.packageType ?? "blister") === "bottle" ||
(med.packageType ?? "blister") === "tube" ||
(med.packageType ?? "blister") === "liquid_container"
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
const totalPills = isAmountBasedPackageType(med.packageType)
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
return {
id: med.id,
name: med.name,
@@ -133,7 +131,7 @@ export async function shareRoutes(app: FastifyInstance) {
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: med.packageType ?? "blister",
packageType: normalizePackageType(med.packageType),
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
+12 -6
View File
@@ -8,6 +8,12 @@ import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js";
import {
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
normalizePackageType,
} from "../utils/package-profiles.js";
// Import shared utilities
import {
type Blister,
@@ -268,9 +274,10 @@ async function getMedicationsNeedingReminder(
const msPerDay = 86_400_000;
for (const row of rows) {
const packageType = normalizePackageType(row.packageType);
// Tube stock reminders are intentionally disabled:
// topical usage in grams cannot be mapped reliably to schedule events.
if ((row.packageType ?? "blister") === "tube") continue;
if (isTubePackageType(packageType)) continue;
const intakes = parseIntakesJson(
row.intakesJson,
@@ -283,10 +290,9 @@ async function getMedicationsNeedingReminder(
start: i.start,
}));
const originalTotalPills =
(row.packageType ?? "blister") === "bottle"
? row.looseTablets + (row.stockAdjustment ?? 0)
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const originalTotalPills = isAmountBasedPackageType(packageType)
? row.looseTablets + (row.stockAdjustment ?? 0)
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
@@ -393,7 +399,7 @@ async function getMedicationsNeedingReminder(
if (daysLeft === null) continue;
const isLiquid = (row.packageType ?? "blister") === "liquid_container";
const isLiquid = isLiquidContainerPackageType(packageType);
const { lowDays, criticalDays } = isLiquid
? getLiquidReminderThresholds(reminderDaysBefore)
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
+32
View File
@@ -0,0 +1,32 @@
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
export type PackageType = (typeof PACKAGE_TYPES)[number];
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
export function normalizePackageType(packageType?: string | null): PackageType {
if (packageType && PACKAGE_TYPE_SET.has(packageType)) {
return packageType as PackageType;
}
return "blister";
}
export function isTubePackageType(packageType?: string | null): boolean {
return normalizePackageType(packageType) === "tube";
}
export function isLiquidContainerPackageType(packageType?: string | null): boolean {
return normalizePackageType(packageType) === "liquid_container";
}
export function isAmountBasedPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
}
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
const normalized = normalizePackageType(packageType);
if (normalized === "tube") return "units";
if (normalized === "liquid_container") return "ml";
return "pills";
}
+3 -2
View File
@@ -4,6 +4,7 @@
*/
import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string };
@@ -36,9 +37,9 @@ export function normalizeIntakeUsageForStock(
): number {
const usage = Number(intake.usage);
if (!Number.isFinite(usage) || usage <= 0) return 0;
if (packageType === "tube") return 0;
if (isTubePackageType(packageType)) return 0;
const isLiquidStock = packageType === "liquid_container" || medicationForm === "liquid";
const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid";
if (!isLiquidStock) return usage;
if (intake.intakeUnit === "tsp") return usage * 5;