import { and, desc, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { medications, refillHistory } 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 { applyOpenApiRouteStandards, genericErrorSchema, idParamsSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, isDiscreteCountPackageType, isPackageAmountPackageType, normalizePackageType, } from "../utils/package-profiles.js"; const refillSchema = z .object({ packsAdded: z.number().int().min(0).default(0), loosePillsAdded: z.number().int().min(0).default(0), quantityAdded: z.number().int().min(0).default(0), usePrescription: z.boolean().default(false), }) .refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, { message: "Must add at least one pack or some quantity", }); const refillBodyOpenApiSchema = { type: "object", properties: { packsAdded: { type: "integer", minimum: 0, default: 0 }, loosePillsAdded: { type: "integer", minimum: 0, default: 0 }, quantityAdded: { type: "integer", minimum: 0, default: 0 }, usePrescription: { type: "boolean", default: false }, }, description: "Provide at least one pack or some quantity.", example: { packsAdded: 1, loosePillsAdded: 4, quantityAdded: 4, usePrescription: true, }, } as const; const refillResponseSchema = { type: "object", properties: { success: { type: "boolean" }, refill: { type: "object", properties: { id: { type: "number" }, packsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" }, quantityAdded: { type: "number" }, totalPillsAdded: { type: "number" }, refillDate: { type: "string", format: "date-time" }, }, }, newStock: { type: "object", properties: { packCount: { type: "integer" }, looseTablets: { type: "integer" }, totalPills: { type: "number" }, }, }, prescription: { type: "object", properties: { used: { type: "boolean" }, remainingRefills: { type: "integer" }, authorizedRefills: { type: "integer" }, lowRefillThreshold: { type: "integer" }, enabled: { type: "boolean" }, }, }, }, } as const; const refillHistoryItemSchema = { type: "object", properties: { id: { type: "number" }, packsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" }, quantityAdded: { type: "number" }, totalPillsAdded: { type: "number" }, usedPrescription: { type: "boolean" }, refillDate: { type: "string", format: "date-time" }, }, } as const; export async function refillRoutes(app: FastifyInstance) { // All refill routes require auth app.addHook("preHandler", requireAuth); applyOpenApiRouteStandards(app, { tag: "refills", protectedByDefault: true }); // Helper to get user ID from request async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { if (!env.AUTH_ENABLED) { return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; if (!authUser) { reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" }); throw new Error("AUTH_REQUIRED"); } return authUser.id; } // POST /medications/:id/refill - Add stock to medication app.post<{ Params: { id: string } }>( "/medications/:id/refill", { schema: { params: idParamsSchema, body: refillBodyOpenApiSchema, response: { 200: refillResponseSchema, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, 404: genericErrorSchema, 409: genericErrorSchema, }, }, }, async (req, reply) => { const parsed = refillSchema.safeParse(req.body); if (!parsed.success) return reply.status(400).send(parsed.error.format()); const medId = Number(req.params.id); if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id"); const userId = await getUserId(req, reply); // Verify ownership const [med] = await db .select() .from(medications) .where(and(eq(medications.id, medId), eq(medications.userId, userId))); if (!med) return reply.notFound("Medication not found"); const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data; const packageType = normalizePackageType(med.packageType); const isDiscreteCountPackage = isDiscreteCountPackageType(packageType); const isAmountBased = isAmountBasedPackageType(packageType); const isPackageAmountPackage = isPackageAmountPackageType(packageType); const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister; const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0); const fallbackAmountPerPackage = Math.max( 1, Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1)) ); const amountPerPackage = Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0 ? configuredAmountPerPackage : fallbackAmountPerPackage; const requestedPackAdds = Math.max(0, packsAdded); const requestedLooseAdds = Math.max(0, loosePillsAdded); const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds); let effectivePacksAdded = requestedPackAdds; if (isDiscreteCountPackage) { effectivePacksAdded = 0; } const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds; const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; const totalPillsAdded = isAmountBased ? effectiveLoosePillsAdded : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded; if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) { return reply.status(400).send({ error: "Must add at least one pack or some loose pills" }); } if (usePrescription) { if (!(med.prescriptionEnabled ?? false)) { return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" }); } if (remainingPrescriptionRefills <= 0) { return reply.status(409).send({ error: "No remaining prescription refills" }); } if (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) { return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" }); } } const refillBaselineAt = new Date(); const baselineStockBeforeRefill = isAmountBased ? med.looseTablets + (med.stockAdjustment ?? 0) : med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0); const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded; // Update medication stock. Refill establishes a new persisted stock baseline and resets // `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math. let newPackCount = med.packCount + effectivePacksAdded; let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; let newStockAdjustment = med.stockAdjustment ?? 0; let newTotalAmount = med.totalPills ?? med.looseTablets; if (isDiscreteCountPackage) { newLooseTablets = targetCurrentStock; newTotalAmount = Math.max(newTotalAmount, targetCurrentStock); newStockAdjustment = 0; } else if (isPackageAmountPackage) { newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage)); newLooseTablets = targetCurrentStock; newTotalAmount = targetCurrentStock; newStockAdjustment = 0; } else { const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets; newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill; } let consumedRefills = 0; if (usePrescription) { consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded; } const newRemainingRefills = usePrescription ? Math.max(0, remainingPrescriptionRefills - consumedRefills) : (med.prescriptionRemainingRefills ?? null); const updatePayload: { packCount: number; looseTablets: number; stockAdjustment: number; totalPills?: number; packageAmountValue?: number; prescriptionRemainingRefills: number | null; lastStockCorrectionAt: Date; updatedAt: Date; } = { packCount: newPackCount, looseTablets: newLooseTablets, stockAdjustment: newStockAdjustment, prescriptionRemainingRefills: newRemainingRefills, lastStockCorrectionAt: refillBaselineAt, updatedAt: refillBaselineAt, }; if (isPackageAmountPackage) { updatePayload.totalPills = newTotalAmount; updatePayload.packageAmountValue = amountPerPackage; } await db .update(medications) .set(updatePayload) .where(and(eq(medications.id, medId), eq(medications.userId, userId))); // Create refill history entry const [refill] = await db .insert(refillHistory) .values({ medicationId: medId, userId, packsAdded: effectivePacksAdded, loosePillsAdded: effectiveLoosePillsAdded, usedPrescription: usePrescription, }) .returning(); return { success: true, refill: { id: refill.id, packsAdded: effectivePacksAdded, loosePillsAdded: effectiveLoosePillsAdded, quantityAdded: totalPillsAdded, totalPillsAdded, refillDate: refill.refillDate, }, newStock: { packCount: newPackCount, looseTablets: newLooseTablets, totalPills: targetCurrentStock, }, prescription: { used: usePrescription, remainingRefills: newRemainingRefills, authorizedRefills: med.prescriptionAuthorizedRefills ?? null, lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, enabled: med.prescriptionEnabled ?? false, }, }; } ); // GET /medications/:id/refills - Get refill history for a medication app.get<{ Params: { id: string } }>( "/medications/:id/refills", { schema: { params: idParamsSchema, response: { 200: { type: "array", items: refillHistoryItemSchema }, 400: genericErrorSchema, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const medId = Number(req.params.id); if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id"); const userId = await getUserId(req, reply); // Verify ownership const [med] = await db .select() .from(medications) .where(and(eq(medications.id, medId), eq(medications.userId, userId))); if (!med) return reply.notFound("Medication not found"); // Get refill history, newest first const refills = await db .select() .from(refillHistory) .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))) .orderBy(desc(refillHistory.refillDate)); const packageType = normalizePackageType(med.packageType); const isDiscreteCountPackage = isDiscreteCountPackageType(packageType); const isAmountBased = isAmountBasedPackageType(packageType); const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister; return refills.map((r) => ({ id: r.id, packsAdded: r.packsAdded, loosePillsAdded: r.loosePillsAdded, quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, usedPrescription: r.usedPrescription ?? false, refillDate: r.refillDate, })); } ); }