feat: replace hardcoded package assumptions with profile abstraction (#379)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user