import { resolve } from "node:path"; import { and, eq, like } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { getDataDir } from "../db/db-utils.js"; import { doseTracking, medications, userSettings } 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 { ALLOWED_IMAGE_MIME_TYPES, removeImageFiles, streamToBuffer, writeOptimizedImageSet, } from "../utils/image-upload.js"; import { applyOpenApiRouteStandards, genericErrorSchema, idParamsSchema, successResponseSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, normalizePackageType, PACKAGE_TYPES, } from "../utils/package-profiles.js"; import { countScheduledOccurrencesInRange, forEachScheduledOccurrenceInRange, getDateOnlyTimestamp, getNextScheduledOccurrenceTime, getScheduleMatchWindowMs, type Intake, normalizeIntake, normalizeIntakeUsageForStock, parseIntakesJson, parseLocalDateTime, parseTakenByJson, } from "../utils/scheduler-utils.js"; const IMAGES_DIR = resolve(getDataDir(), "images"); function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" { return value === "ml" || value === "tsp" || value === "tbsp"; } function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> { if (!intakesJson) return []; try { const parsed = JSON.parse(intakesJson); if (!Array.isArray(parsed)) return []; return parsed.map((item: unknown) => { if (!item || typeof item !== "object") return null; const unit = (item as Record).intakeUnit; return isIntakeUnit(unit) ? unit : null; }); } catch { return []; } } function parseIntakesWithUnits( intakesJson: string | null | undefined, legacyRow: { usageJson: string; everyJson: string; startJson: string }, medicationIntakeRemindersEnabled?: boolean ): Intake[] { const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled); const rawUnits = parseRawIntakeUnits(intakesJson); if (rawUnits.length === 0) return intakes; return intakes.map((intake, idx) => ({ ...intake, intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null, })); } function normalizeDateTime(value: unknown): string | null { if (value == null) { return null; } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value.toISOString(); } if (typeof value === "number") { const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value; const date = new Date(timestampMs); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } if (typeof value === "string") { const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } return null; } // New intake schema with per-intake takenBy const intakeSchema = z.object({ usage: z.number().nonnegative(), every: z.number().int().min(1), start: z.string().datetime({ local: true }), scheduleMode: z.unknown().optional(), weekdays: z.unknown().optional(), intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(), takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting }); // Legacy blister schema (for backward compatibility during transition) const blisterSchema = z.object({ usage: z.number().nonnegative(), every: z.number().int().min(1), start: z.string().datetime({ local: true }), }); 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 medicationStartDateSchema = z .union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()]) .optional(); const medicationEndDateSchema = z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()]).optional(); const medicationSchema = z .object({ name: z.string().trim().max(100).default(""), genericName: z.string().trim().max(100).nullable().optional(), takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback) medicationForm: medicationFormSchema, pillForm: pillFormSchema.nullable().optional(), lifecycleCategory: lifecycleCategorySchema, packageType: packageTypeSchema, packCount: z.number().int().min(0).default(1), blistersPerPack: z.number().int().min(1).default(1), pillsPerBlister: z.number().int().min(1).default(1), packageAmountValue: z.number().int().min(0).default(0), packageAmountUnit: z.enum(["ml", "g"]).default("ml"), totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity looseTablets: z.number().int().min(0).default(0), pillWeightMg: z.number().nonnegative().nullable().optional(), doseUnit: doseUnitSchema, medicationStartDate: medicationStartDateSchema, medicationEndDate: medicationEndDateSchema, autoMarkObsoleteAfterEndDate: z.boolean().default(true), expiryDate: z.string().nullable().optional(), notes: z.string().max(2000).nullable().optional(), prescriptionEnabled: z.boolean().default(false), prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(), prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(), prescriptionLowRefillThreshold: z.number().int().min(0).default(1), prescriptionExpiryDate: z.string().nullable().optional(), intakeRemindersEnabled: z.boolean().default(false), // Medication-level (deprecated, kept for backward compat) // Accept either new intakes format or legacy blisters format intakes: z.array(intakeSchema).min(1).max(12).optional(), blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format }) .refine((data) => (data.name && data.name.length > 0) || (data.genericName && data.genericName.length > 0), { message: "Either 'name' or 'genericName' must be provided", path: ["name"], }) .refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" }) .refine( (data) => { const startDate = data.medicationStartDate ?? ""; if (!startDate) return true; const scheduleStarts = data.intakes?.map((i) => i.start) ?? data.blisters?.map((b) => b.start) ?? []; return scheduleStarts.every((scheduleStart) => scheduleStart.slice(0, 10) >= startDate); }, { message: "Medication start date must be on or before all intake dates", path: ["medicationStartDate"], } ) .refine( (data) => { const startDate = data.medicationStartDate ?? ""; const endDate = data.medicationEndDate ?? ""; if (!startDate || !endDate) return true; return startDate <= endDate; }, { message: "Medication end date must be on or after medication start date", path: ["medicationEndDate"], } ) .refine( (data) => { if (data.medicationForm === "capsule" || data.medicationForm === "tablet") { return data.pillForm == null || data.pillForm === "capsule" || data.pillForm === "tablet"; } return true; }, { message: "pillForm must be capsule or tablet for capsule/tablet medications", path: ["pillForm"], } ) .refine( (data) => { if (data.medicationForm === "topical") { return isTubePackageType(data.packageType); } return true; }, { message: "Topical medications must use tube package type", path: ["packageType"], } ) .refine( (data) => { if (data.medicationForm === "liquid") { return isLiquidContainerPackageType(data.packageType); } return true; }, { message: "Liquid medications must use liquid_container package type", path: ["packageType"], } ) .refine( (data) => { if (data.medicationForm === "capsule" || data.medicationForm === "tablet") { return !isTubePackageType(data.packageType) && !isLiquidContainerPackageType(data.packageType); } return true; }, { message: "Capsule and tablet medications cannot use tube or liquid_container package type", path: ["packageType"], } ) .refine( (data) => { const schedules = data.intakes ?? data.blisters ?? []; if (data.pillForm !== "capsule") return true; return schedules.every((entry) => Number.isInteger(entry.usage)); }, { message: "Fractional intake is not allowed for capsule", path: ["intakes"], } ) .refine( (data) => { if (!data.prescriptionEnabled) return true; if (data.prescriptionAuthorizedRefills == null || data.prescriptionRemainingRefills == null) return false; return data.prescriptionRemainingRefills <= data.prescriptionAuthorizedRefills; }, { message: "When prescription is enabled, remaining refills must be <= authorized refills", path: ["prescriptionRemainingRefills"], } ) .refine( (data) => { if (!data.prescriptionEnabled) return true; if (data.prescriptionAuthorizedRefills == null) return false; return data.prescriptionLowRefillThreshold <= data.prescriptionAuthorizedRefills; }, { message: "When prescription is enabled, low refill threshold must be <= authorized refills", path: ["prescriptionLowRefillThreshold"], } ); const intakeOpenApiSchema = { type: "object", required: ["usage", "every", "start"], properties: { usage: { type: "number", minimum: 0 }, every: { type: "integer", minimum: 1 }, start: { type: "string", description: "ISO datetime string; timezone suffix optional." }, scheduleMode: { type: "string", enum: ["interval", "weekdays"] }, weekdays: { type: "array", items: { type: "string", enum: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] }, }, intakeUnit: { type: ["string", "null"], enum: ["ml", "tsp", "tbsp", null] }, takenBy: { type: ["string", "null"], maxLength: 100 }, intakeRemindersEnabled: { type: "boolean" }, }, } as const; const blisterOpenApiSchema = { type: "object", required: ["usage", "every", "start"], properties: { usage: { type: "number", minimum: 0 }, every: { type: "integer", minimum: 1 }, start: { type: "string", description: "ISO datetime string; timezone suffix optional." }, }, } as const; const medicationBodyOpenApiSchema = { type: "object", properties: { name: { type: "string", maxLength: 100 }, genericName: { type: ["string", "null"], maxLength: 100 }, takenBy: { type: "array", items: { type: "string", maxLength: 100 } }, medicationForm: { type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, pillForm: { type: ["string", "null"], enum: ["capsule", "tablet", null] }, lifecycleCategory: { type: "string", enum: ["refill_when_empty", "treatment_period"] }, packageType: { type: "string", enum: PACKAGE_TYPES }, packCount: { type: "integer", minimum: 0 }, blistersPerPack: { type: "integer", minimum: 1 }, pillsPerBlister: { type: "integer", minimum: 1 }, packageAmountValue: { type: "integer", minimum: 0 }, packageAmountUnit: { type: "string", enum: ["ml", "g"] }, 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"] }, medicationStartDate: { anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }], }, medicationEndDate: { anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }], }, autoMarkObsoleteAfterEndDate: { type: "boolean" }, expiryDate: { type: ["string", "null"] }, notes: { type: ["string", "null"], maxLength: 2000 }, prescriptionEnabled: { type: "boolean" }, prescriptionAuthorizedRefills: { type: ["integer", "null"], minimum: 0 }, prescriptionRemainingRefills: { type: ["integer", "null"], minimum: 0 }, prescriptionLowRefillThreshold: { type: "integer", minimum: 0 }, prescriptionExpiryDate: { type: ["string", "null"] }, intakeRemindersEnabled: { type: "boolean" }, intakes: { type: "array", items: intakeOpenApiSchema }, blisters: { type: "array", items: blisterOpenApiSchema }, }, description: "Medication payload. Runtime validation allows defaults and legacy shapes; provide either intakes or legacy blisters.", example: { name: "Ibuprofen 400", genericName: "Ibuprofen", takenBy: ["Daniel"], medicationForm: "tablet", pillForm: "tablet", lifecycleCategory: "refill_when_empty", packageType: "box", packCount: 1, blistersPerPack: 2, pillsPerBlister: 10, totalPills: 20, looseTablets: 8, pillWeightMg: 400, doseUnit: "mg", medicationStartDate: "2026-03-01", autoMarkObsoleteAfterEndDate: false, expiryDate: "2027-12-31", notes: "Take after meals.", prescriptionEnabled: true, prescriptionAuthorizedRefills: 3, prescriptionRemainingRefills: 2, prescriptionLowRefillThreshold: 1, prescriptionExpiryDate: "2026-12-31", intakeRemindersEnabled: true, intakes: [ { usage: 1, every: 8, start: "2026-03-11T08:00:00.000Z", scheduleMode: "interval", weekdays: [], takenBy: "Daniel", intakeRemindersEnabled: true, }, ], }, } as const; const medicationResponseSchema = { type: "object", properties: { id: { type: "number" }, name: { type: "string" }, genericName: { type: ["string", "null"] }, takenBy: { type: "array", items: { type: "string" } }, medicationForm: { type: "string" }, pillForm: { type: ["string", "null"] }, lifecycleCategory: { type: "string" }, packageType: { type: "string" }, packCount: { type: "integer" }, blistersPerPack: { type: "integer" }, pillsPerBlister: { type: "integer" }, packageAmountValue: { type: "integer" }, packageAmountUnit: { type: "string" }, totalPills: { type: ["number", "null"] }, looseTablets: { type: "number" }, stockAdjustment: { type: "number" }, lastStockCorrectionAt: { type: ["string", "null"] }, pillWeightMg: { type: ["number", "null"] }, doseUnit: { type: "string" }, medicationStartDate: { type: ["string", "null"] }, medicationEndDate: { type: ["string", "null"] }, autoMarkObsoleteAfterEndDate: { type: "boolean" }, intakes: { type: "array", items: intakeOpenApiSchema }, blisters: { type: "array", items: blisterOpenApiSchema }, imageUrl: { type: ["string", "null"] }, expiryDate: { type: ["string", "null"] }, notes: { type: ["string", "null"] }, intakeRemindersEnabled: { type: "boolean" }, isObsolete: { type: "boolean" }, obsoleteAt: { type: ["string", "null"] }, prescriptionEnabled: { type: "boolean" }, prescriptionAuthorizedRefills: { type: ["integer", "null"] }, prescriptionRemainingRefills: { type: ["integer", "null"] }, prescriptionLowRefillThreshold: { type: "integer" }, prescriptionExpiryDate: { type: ["string", "null"] }, dismissedUntil: { type: ["string", "null"] }, updatedAt: { type: ["string", "null"], format: "date-time" }, }, } as const; const usageRequestSchema = { type: "object", required: ["startDate", "endDate"], properties: { startDate: { type: "string", format: "date-time" }, endDate: { type: "string", format: "date-time" }, includeUntilStart: { type: "boolean", default: false }, }, example: { startDate: "2026-03-01T00:00:00.000Z", endDate: "2026-03-31T23:59:59.000Z", includeUntilStart: false, }, } as const; const usageItemSchema = { type: "object", properties: { medicationId: { type: "number" }, medicationName: { type: "string" }, totalPills: { type: "number" }, currentPills: { type: "number" }, plannerUsage: { type: "number" }, blisterSize: { type: "number" }, blistersNeeded: { type: "number" }, fullBlisters: { type: "number" }, loosePills: { type: "number" }, enough: { type: "boolean" }, packageType: { type: "string" }, }, } as const; const stockAdjustmentBodySchema = { type: "object", required: ["stockAdjustment"], properties: { stockAdjustment: { type: "number" }, looseTablets: { type: "integer", minimum: 0 }, totalPills: { type: "integer", minimum: 0 }, packageAmountValue: { type: "integer", minimum: 0 }, packCount: { type: "integer", minimum: 0 }, }, example: { stockAdjustment: -2, looseTablets: 6, totalPills: 16, packCount: 1, }, } as const; const stockAdjustmentResponseSchema = { type: "object", properties: { id: { type: "number" }, stockAdjustment: { type: "number" }, lastStockCorrectionAt: { type: "string" }, updatedAt: { type: "string", format: "date-time" }, }, } as const; const obsoleteStateResponseSchema = { type: "object", properties: { id: { type: "number" }, isObsolete: { type: "boolean" }, obsoleteAt: { type: "string" }, updatedAt: { type: "string", format: "date-time" }, }, } as const; const dismissUntilBodySchema = { type: "object", required: ["medicationIds", "until"], properties: { medicationIds: { type: "array", minItems: 1, items: { type: "integer", minimum: 1 } }, until: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, }, example: { medicationIds: [1, 2], until: "2026-03-20", }, } as const; export async function medicationRoutes(app: FastifyInstance) { // All medication routes require auth app.addHook("preHandler", requireAuth); applyOpenApiRouteStandards(app, { tag: "medications", protectedByDefault: true }); // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; if (!authUser) { // This should never happen if requireAuth worked, but be safe reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" }); throw new Error("AUTH_REQUIRED"); } return authUser.id; } app.get<{ Querystring: { includeObsolete?: string } }>( "/medications", { schema: { querystring: { type: "object", properties: { includeObsolete: { type: "string", enum: ["true", "false"] }, }, }, response: { 200: { type: "array", items: medicationResponseSchema }, 401: genericErrorSchema, }, }, }, async (request, reply) => { const userId = await getUserId(request, reply); const includeObsolete = request.query.includeObsolete === "true"; const initialRows = await db .select() .from(medications) .where(eq(medications.userId, userId)) .orderBy(medications.id); const todayDate = new Date().toISOString().slice(0, 10); for (const row of initialRows) { if (row.isObsolete) continue; if (!(row.autoMarkObsoleteAfterEndDate ?? true)) continue; const endDate = row.medicationEndDate?.slice(0, 10); if (!endDate) continue; if (endDate > todayDate) continue; await db .update(medications) .set({ isObsolete: true, obsoleteAt: new Date(), updatedAt: new Date() }) .where(and(eq(medications.id, row.id), eq(medications.userId, userId))); } const whereClause = includeObsolete ? eq(medications.userId, userId) : and(eq(medications.userId, userId), eq(medications.isObsolete, false)); const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id); return rows.map((row) => { // Parse intakes from new format, falling back to legacy const intakes = parseIntakesWithUnits( row.intakesJson, { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, row.intakeRemindersEnabled ?? false ); return { id: row.id, name: row.name, genericName: row.genericName, takenBy: parseTakenByJson(row.takenByJson), medicationForm: row.medicationForm ?? "tablet", pillForm: row.pillForm ?? null, lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty", packageType: normalizePackageType(row.packageType), packCount: row.packCount ?? 1, blistersPerPack: row.blistersPerPack ?? 1, pillsPerBlister: row.pillsPerBlister ?? 1, packageAmountValue: row.packageAmountValue ?? 0, packageAmountUnit: (row.packageAmountUnit ?? "ml") as "ml" | "g", totalPills: row.totalPills ?? null, looseTablets: row.looseTablets ?? 0, stockAdjustment: row.stockAdjustment ?? 0, lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: row.pillWeightMg, doseUnit: row.doseUnit ?? "mg", medicationStartDate: row.medicationStartDate || null, medicationEndDate: row.medicationEndDate || null, autoMarkObsoleteAfterEndDate: row.autoMarkObsoleteAfterEndDate ?? true, intakes, // New unified format with per-intake takenBy // Legacy blisters format (for backward compat with frontend during transition) blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), imageUrl: row.imageUrl, expiryDate: row.expiryDate, notes: row.notes, intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, isObsolete: row.isObsolete ?? false, obsoleteAt: row.obsoleteAt?.toISOString() ?? null, prescriptionEnabled: row.prescriptionEnabled ?? false, prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null, prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null, prescriptionLowRefillThreshold: row.prescriptionLowRefillThreshold ?? 1, prescriptionExpiryDate: row.prescriptionExpiryDate ?? null, dismissedUntil: row.dismissedUntil ?? null, updatedAt: normalizeDateTime(row.updatedAt), }; }); } ); app.post( "/medications", { schema: { body: medicationBodyOpenApiSchema, response: { 200: medicationResponseSchema, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, }, }, }, async (req, reply) => { const parsed = medicationSchema.safeParse(req.body); if (!parsed.success) return reply.status(400).send(parsed.error.format()); const userId = await getUserId(req, reply); const { name, genericName, takenBy, medicationForm, pillForm, lifecycleCategory, packageType, packCount, blistersPerPack, pillsPerBlister, packageAmountValue, packageAmountUnit, totalPills, looseTablets, pillWeightMg, doseUnit, medicationStartDate, medicationEndDate, autoMarkObsoleteAfterEndDate, expiryDate, notes, prescriptionEnabled, prescriptionAuthorizedRefills, prescriptionRemainingRefills, prescriptionLowRefillThreshold, prescriptionExpiryDate, intakeRemindersEnabled, intakes: inputIntakes, blisters: inputBlisters, } = parsed.data; const normalizedPillForm = medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null; // Convert to unified intakes format let intakes: Intake[]; if (inputIntakes) { intakes = inputIntakes.map((intake) => normalizeIntake(intake)); } else if (inputBlisters) { intakes = inputBlisters.map((blister) => normalizeIntake( { usage: blister.usage, every: blister.every, start: blister.start, intakeUnit: null, takenBy: null, }, intakeRemindersEnabled ?? false ) ); } else { return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); } // Store both formats for backward compatibility const intakesJson = JSON.stringify(intakes); const usageJson = JSON.stringify(intakes.map((s) => s.usage)); const everyJson = JSON.stringify(intakes.map((s) => s.every)); const startJson = JSON.stringify(intakes.map((s) => s.start)); const takenByJson = JSON.stringify(takenBy || []); const [inserted] = await db .insert(medications) .values({ userId, name, genericName: genericName || null, takenByJson, medicationForm: medicationForm ?? "tablet", pillForm: normalizedPillForm, lifecycleCategory: lifecycleCategory ?? "refill_when_empty", packageType: normalizePackageType(packageType), packCount, blistersPerPack, pillsPerBlister, packageAmountValue, packageAmountUnit, totalPills: totalPills || null, looseTablets, pillWeightMg: pillWeightMg || null, doseUnit: doseUnit ?? "mg", medicationStartDate: medicationStartDate ?? "", medicationEndDate: medicationEndDate || null, autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true, expiryDate: expiryDate || null, notes: notes || null, prescriptionEnabled: prescriptionEnabled ?? false, prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, prescriptionExpiryDate: prescriptionExpiryDate || null, intakeRemindersEnabled: intakeRemindersEnabled ?? false, intakesJson, usageJson, everyJson, startJson, }) .returning(); return { id: inserted.id, name: inserted.name, genericName: inserted.genericName, takenBy: parseTakenByJson(inserted.takenByJson), medicationForm: inserted.medicationForm ?? "tablet", pillForm: inserted.pillForm ?? null, lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty", packageType: normalizePackageType(inserted.packageType), packCount: inserted.packCount, blistersPerPack: inserted.blistersPerPack, pillsPerBlister: inserted.pillsPerBlister, packageAmountValue: inserted.packageAmountValue ?? 0, packageAmountUnit: (inserted.packageAmountUnit ?? "ml") as "ml" | "g", totalPills: inserted.totalPills ?? null, looseTablets: inserted.looseTablets, stockAdjustment: inserted.stockAdjustment ?? 0, lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: inserted.pillWeightMg, doseUnit: inserted.doseUnit ?? "mg", medicationStartDate: inserted.medicationStartDate || null, medicationEndDate: inserted.medicationEndDate || null, autoMarkObsoleteAfterEndDate: inserted.autoMarkObsoleteAfterEndDate ?? true, intakes, blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), imageUrl: inserted.imageUrl, expiryDate: inserted.expiryDate, notes: inserted.notes, intakeRemindersEnabled: inserted.intakeRemindersEnabled, isObsolete: inserted.isObsolete ?? false, obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null, prescriptionEnabled: inserted.prescriptionEnabled ?? false, prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null, prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null, prescriptionLowRefillThreshold: inserted.prescriptionLowRefillThreshold ?? 1, prescriptionExpiryDate: inserted.prescriptionExpiryDate ?? null, updatedAt: normalizeDateTime(inserted.updatedAt), }; } ); app.put<{ Params: { id: string } }>( "/medications/:id", { schema: { params: idParamsSchema, body: medicationBodyOpenApiSchema, response: { 200: medicationResponseSchema, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const parsed = medicationSchema.safeParse(req.body); if (!parsed.success) return reply.status(400).send(parsed.error.format()); const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); const userId = await getUserId(req, reply); // Verify ownership const [existing] = await db .select() .from(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); const { name, genericName, takenBy, medicationForm, pillForm, lifecycleCategory, packageType, packCount, blistersPerPack, pillsPerBlister, packageAmountValue, packageAmountUnit, totalPills, looseTablets, pillWeightMg, doseUnit, medicationStartDate, medicationEndDate, autoMarkObsoleteAfterEndDate, expiryDate, notes, prescriptionEnabled, prescriptionAuthorizedRefills, prescriptionRemainingRefills, prescriptionLowRefillThreshold, prescriptionExpiryDate, intakeRemindersEnabled, intakes: inputIntakes, blisters: inputBlisters, } = parsed.data; const normalizedPillForm = medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null; // Convert to unified intakes format let intakes: Intake[]; if (inputIntakes) { intakes = inputIntakes.map((intake) => normalizeIntake(intake)); } else if (inputBlisters) { intakes = inputBlisters.map((blister) => normalizeIntake( { usage: blister.usage, every: blister.every, start: blister.start, intakeUnit: null, takenBy: null, }, intakeRemindersEnabled ?? false ) ); } else { return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); } // Store both formats for backward compatibility const intakesJson = JSON.stringify(intakes); const usageJson = JSON.stringify(intakes.map((s) => s.usage)); const everyJson = JSON.stringify(intakes.map((s) => s.every)); const startJson = JSON.stringify(intakes.map((s) => s.start)); const takenByJson = JSON.stringify(takenBy || []); // If stock-defining fields changed, reset stockAdjustment so the new // base stock reflects actual inventory. This prevents the old // correction offset from skewing the total after an edit. const stockFieldsChanged = existing.packCount !== packCount || existing.blistersPerPack !== blistersPerPack || existing.pillsPerBlister !== pillsPerBlister || (existing.looseTablets ?? 0) !== (looseTablets ?? 0); const stockResetFields = stockFieldsChanged ? { stockAdjustment: 0, lastStockCorrectionAt: new Date() } : {}; const result = await db .update(medications) .set({ name, genericName: genericName || null, takenByJson, medicationForm: medicationForm ?? "tablet", pillForm: normalizedPillForm, lifecycleCategory: lifecycleCategory ?? "refill_when_empty", packageType: normalizePackageType(packageType), packCount, blistersPerPack, pillsPerBlister, totalPills: totalPills || null, packageAmountValue, packageAmountUnit, looseTablets, pillWeightMg: pillWeightMg || null, doseUnit: doseUnit ?? "mg", medicationStartDate: medicationStartDate ?? "", medicationEndDate: medicationEndDate || null, autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true, expiryDate: expiryDate || null, notes: notes || null, prescriptionEnabled: prescriptionEnabled ?? false, prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, prescriptionExpiryDate: prescriptionExpiryDate || null, intakeRemindersEnabled: intakeRemindersEnabled ?? false, intakesJson, usageJson, everyJson, startJson, updatedAt: new Date(), ...stockResetFields, }) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .returning(); if (!result.length) return reply.notFound(); // --------------------------------------------------------------- // Migrate dose tracking IDs when intake schedule changes // --------------------------------------------------------------- // Parse old intakes from the existing medication row const oldIntakes = parseIntakesWithUnits( existing.intakesJson, { usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson }, existing.intakeRemindersEnabled ); // Get all dose tracking entries for this medication const allDoses = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`))); if (allDoses.length > 0) { // Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs const now = new Date(); const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) { const oldIntake = oldIntakes[idx]; const newIntake = intakes[idx]; // Skip if this intake index doesn't exist in both old and new if (!oldIntake || !newIntake) continue; const oldStart = parseLocalDateTime(oldIntake.start); const newStart = parseLocalDateTime(newIntake.start); // Check if start date or schedule changed (time-of-day changes don't matter for dateOnlyMs) const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime(); const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime(); const scheduleUnchanged = oldStartDateOnly === newStartDateOnly && oldIntake.every === newIntake.every && oldIntake.scheduleMode === newIntake.scheduleMode && (oldIntake.weekdays ?? []).join(",") === (newIntake.weekdays ?? []).join(","); if (scheduleUnchanged) { continue; // No schedule change that affects dose IDs } // Build set of new valid dateOnlyMs values for this intake const newDates = new Set(); forEachScheduledOccurrenceInRange(newIntake, newStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => { newDates.add(getDateOnlyTimestamp(new Date(occurrenceMs))); }); // Build set of old dateOnlyMs values with mapping to nearest new date const oldToNewMap = new Map(); const scheduleMatchWindowMs = getScheduleMatchWindowMs(newIntake); forEachScheduledOccurrenceInRange(oldIntake, oldStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => { const oldDateMs = getDateOnlyTimestamp(new Date(occurrenceMs)); let bestMatch: number | null = null; let bestDistance = Infinity; for (const newDateMs of newDates) { const distance = Math.abs(newDateMs - oldDateMs); if (distance < bestDistance && distance <= scheduleMatchWindowMs) { bestDistance = distance; bestMatch = newDateMs; } } if (bestMatch !== null && bestMatch !== oldDateMs) { oldToNewMap.set(oldDateMs, bestMatch); newDates.delete(bestMatch); } }); // Apply migrations to dose tracking entries if (oldToNewMap.size > 0) { const prefix = `${idNum}-${idx}-`; const dosesToMigrate = allDoses.filter((d) => d.doseId.startsWith(prefix)); for (const dose of dosesToMigrate) { const parts = dose.doseId.split("-"); if (parts.length >= 3) { const oldTimestamp = parseInt(parts[2], 10); const newTimestamp = oldToNewMap.get(oldTimestamp); if (newTimestamp !== undefined) { // Replace the timestamp in the dose ID, keeping any person suffix const newDoseId = `${idNum}-${idx}-${newTimestamp}${parts.length > 3 ? `-${parts.slice(3).join("-")}` : ""}`; await db.update(doseTracking).set({ doseId: newDoseId }).where(eq(doseTracking.id, dose.id)); } } } } } // Also clean up dose tracking entries before the earliest new start date const earliestStartDate = intakes.reduce((min, b) => { const d = parseLocalDateTime(b.start); // Use date-only (midnight) to match dose ID format const dateOnly = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); return dateOnly < min ? dateOnly : min; }, Infinity); if (!Number.isNaN(earliestStartDate)) { // Re-fetch after possible migrations const updatedDoses = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`))); const dosesToDelete = updatedDoses.filter((dose) => { const parts = dose.doseId.split("-"); if (parts.length >= 3) { const timestamp = parseInt(parts[2], 10); return !Number.isNaN(timestamp) && timestamp < earliestStartDate; } return false; }); for (const dose of dosesToDelete) { await db.delete(doseTracking).where(eq(doseTracking.id, dose.id)); } } } return { id: result[0].id, name: result[0].name, genericName: result[0].genericName, takenBy: parseTakenByJson(result[0].takenByJson), medicationForm: result[0].medicationForm ?? "tablet", pillForm: result[0].pillForm ?? null, lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty", packageType: normalizePackageType(result[0].packageType), packCount: result[0].packCount, blistersPerPack: result[0].blistersPerPack, pillsPerBlister: result[0].pillsPerBlister, packageAmountValue: result[0].packageAmountValue ?? 0, packageAmountUnit: (result[0].packageAmountUnit ?? "ml") as "ml" | "g", totalPills: result[0].totalPills ?? null, looseTablets: result[0].looseTablets, stockAdjustment: result[0].stockAdjustment ?? 0, lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: result[0].pillWeightMg, doseUnit: result[0].doseUnit ?? "mg", medicationStartDate: result[0].medicationStartDate || null, medicationEndDate: result[0].medicationEndDate || null, autoMarkObsoleteAfterEndDate: result[0].autoMarkObsoleteAfterEndDate ?? true, intakes, blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), imageUrl: result[0].imageUrl, expiryDate: result[0].expiryDate, notes: result[0].notes, intakeRemindersEnabled: result[0].intakeRemindersEnabled, isObsolete: result[0].isObsolete ?? false, obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null, prescriptionEnabled: result[0].prescriptionEnabled ?? false, prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null, prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null, prescriptionLowRefillThreshold: result[0].prescriptionLowRefillThreshold ?? 1, prescriptionExpiryDate: result[0].prescriptionExpiryDate ?? null, updatedAt: normalizeDateTime(result[0].updatedAt), }; } ); app.post<{ Params: { id: string } }>( "/medications/:id/obsolete", { schema: { params: idParamsSchema, response: { 200: obsoleteStateResponseSchema, 400: genericErrorSchema, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); const userId = await getUserId(req, reply); const [existing] = await db .select() .from(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); const [updated] = await db .update(medications) .set({ isObsolete: true, obsoleteAt: new Date(), updatedAt: new Date(), }) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .returning(); return { id: updated.id, isObsolete: updated.isObsolete ?? false, obsoleteAt: updated.obsoleteAt?.toISOString() ?? null, updatedAt: normalizeDateTime(updated.updatedAt), }; } ); app.post<{ Params: { id: string } }>( "/medications/:id/reactivate", { schema: { params: idParamsSchema, response: { 200: obsoleteStateResponseSchema, 400: genericErrorSchema, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); const userId = await getUserId(req, reply); const [existing] = await db .select() .from(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); const [updated] = await db .update(medications) .set({ isObsolete: false, obsoleteAt: null, updatedAt: new Date(), }) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .returning(); return { id: updated.id, isObsolete: updated.isObsolete ?? false, obsoleteAt: updated.obsoleteAt?.toISOString() ?? null, updatedAt: normalizeDateTime(updated.updatedAt), }; } ); // Stock correction endpoint - updates stockAdjustment and optionally base amount fields for amount-based corrections // Also sets lastStockCorrectionAt so consumed doses before this point don't count app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number; totalPills?: number; packageAmountValue?: number; packCount?: number; }; }>( "/medications/:id/stock-adjustment", { schema: { params: idParamsSchema, body: stockAdjustmentBodySchema, response: { 200: stockAdjustmentResponseSchema, 400: genericErrorSchema, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); const userId = await getUserId(req, reply); // Verify ownership const [existing] = await db .select() .from(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); const { stockAdjustment, looseTablets, totalPills, packageAmountValue, packCount } = req.body as { stockAdjustment: number; looseTablets?: number; totalPills?: number; packageAmountValue?: number; packCount?: number; }; if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number"); if ( looseTablets !== undefined && (typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0) ) { return reply.badRequest("looseTablets must be a non-negative integer"); } if ( totalPills !== undefined && (typeof totalPills !== "number" || !Number.isInteger(totalPills) || totalPills < 0) ) { return reply.badRequest("totalPills must be a non-negative integer"); } if ( packageAmountValue !== undefined && (typeof packageAmountValue !== "number" || !Number.isInteger(packageAmountValue) || packageAmountValue < 0) ) { return reply.badRequest("packageAmountValue must be a non-negative integer"); } if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 0)) { return reply.badRequest("packCount must be a non-negative integer"); } const updateFields: { stockAdjustment: number; lastStockCorrectionAt: Date; updatedAt: Date; looseTablets?: number; totalPills?: number | null; packageAmountValue?: number; packCount?: number; } = { stockAdjustment, lastStockCorrectionAt: new Date(), updatedAt: new Date(), }; const packageType = normalizePackageType(existing.packageType); const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType); const allowsBottleCapacityUpdate = packageType === "bottle"; if (allowsAmountBaseUpdate) { if (totalPills !== undefined) updateFields.totalPills = totalPills; if (looseTablets !== undefined) updateFields.looseTablets = looseTablets; if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue; } if (allowsBottleCapacityUpdate && totalPills !== undefined) { updateFields.totalPills = totalPills; } if (packCount !== undefined) updateFields.packCount = packCount; if (looseTablets !== undefined) { updateFields.looseTablets = looseTablets; } const result = await db .update(medications) .set(updateFields) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .returning(); if (!result.length) return reply.notFound(); return { id: result[0].id, stockAdjustment: result[0].stockAdjustment ?? 0, lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, updatedAt: normalizeDateTime(result[0].updatedAt), }; } ); app.delete<{ Params: { id: string } }>( "/medications/:id", { schema: { params: idParamsSchema, response: { 204: { type: "null" }, 400: genericErrorSchema, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); const userId = await getUserId(req, reply); // Delete associated image if exists (with ownership check) const [existing] = await db .select() .from(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); const deleted = await db .delete(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .returning(); if (!deleted.length) return reply.notFound(); return reply.status(204).send(); } ); // Upload medication image app.post<{ Params: { id: string } }>( "/medications/:id/image", { schema: { params: idParamsSchema, consumes: ["multipart/form-data"], response: { 200: { type: "object", properties: { success: { type: "boolean" }, imageUrl: { type: "string" }, }, }, 400: genericErrorSchema, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); const userId = await getUserId(req, reply); const [existing] = await db .select() .from(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); const data = await req.file(); if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" }); if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) { return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" }); } let uploadBuffer: Buffer; try { uploadBuffer = await streamToBuffer(data.file); } catch (error) { if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") { return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" }); } throw error; } let filename: string; try { ({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer)); } catch { return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" }); } // Delete old image if exists if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); await db .update(medications) .set({ imageUrl: filename, updatedAt: new Date() }) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); return { success: true, imageUrl: filename }; } ); // Delete medication image app.delete<{ Params: { id: string } }>( "/medications/:id/image", { schema: { params: idParamsSchema, response: { 204: { type: "null" }, 400: genericErrorSchema, 401: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (req, reply) => { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); const userId = await getUserId(req, reply); const [existing] = await db .select() .from(medications) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); await db .update(medications) .set({ imageUrl: null, updatedAt: new Date() }) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); return reply.status(204).send(); } ); app.post( "/medications/usage", { schema: { body: usageRequestSchema, response: { 200: { type: "array", items: usageItemSchema }, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, }, }, }, async (req, reply) => { const schema = z.object({ startDate: z.string().datetime(), endDate: z.string().datetime(), includeUntilStart: z.boolean().optional().default(false), }); const parsed = schema.safeParse(req.body); if (!parsed.success) return reply.status(400).send(parsed.error.format()); const { startDate, endDate, includeUntilStart } = parsed.data; const start = new Date(startDate); const end = new Date(endDate); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) { return reply.badRequest("Invalid date range"); } const userId = await getUserId(req, reply); const rows = await db .select() .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))) .orderBy(medications.id); const [settingsRow] = await db .select({ stockCalculationMode: userSettings.stockCalculationMode }) .from(userSettings) .where(eq(userSettings.userId, userId)); const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic"; // Get all taken doses for this user to calculate actual consumption const takenDoses = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false))); const takenDoseIdsByMed = new Map>(); const takenDoseTimestamps = new Map(); takenDoses.forEach((dose) => { const parts = dose.doseId.split("-"); if (parts.length < 3) return; const medId = parseInt(parts[0], 10); if (Number.isNaN(medId)) return; if (!takenDoseIdsByMed.has(medId)) { takenDoseIdsByMed.set(medId, new Set()); } takenDoseIdsByMed.get(medId)!.add(dose.doseId); const rawTakenAt = Number(dose.takenAt); let takenAtMs: number; if (Number.isFinite(rawTakenAt)) { takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt; } else { takenAtMs = new Date(dose.takenAt).getTime(); } takenDoseTimestamps.set(dose.doseId, takenAtMs); }); // Use current time as the reference point for "available" stock const now = new Date(); const payload = rows.map((row) => { // Parse intakes from new format, falling back to legacy const intakes = parseIntakesWithUnits( row.intakesJson, { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, row.intakeRemindersEnabled ?? false ); const medForm = row.medicationForm ?? "tablet"; const blisters = intakes.map((i) => ({ usage: normalizeIntakeUsageForStock(i, medForm, row.packageType), every: i.every, start: i.start, scheduleMode: i.scheduleMode, weekdays: i.weekdays, })); const pillsPerBlister = row.pillsPerBlister ?? 1; const packCount = row.packCount ?? 1; const blistersPerPack = row.blistersPerPack ?? 1; const looseTablets = row.looseTablets ?? 0; const stockAdjustment = row.stockAdjustment ?? 0; const packageType = normalizePackageType(row.packageType); // For bottle type, looseTablets IS the current stock (no blister math) 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; const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set(); // Count consumed pills by generating expected doses and checking if they're taken let consumedUntilNow = 0; if (isTopical) { consumedUntilNow = 0; } else if (stockCalculationMode === "automatic") { blisters.forEach((blister, blisterIdx) => { const blisterStart = parseLocalDateTime(blister.start).getTime(); if (Number.isNaN(blisterStart)) return; const effectiveStart = stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart ? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false) : blisterStart; if (effectiveStart === null) return; const intake = intakes[blisterIdx]; const intakePerson = intake?.takenBy; const fallbackPeople = parseTakenByJson(row.takenByJson); let peopleForThisIntake: Array; if (intakePerson) { peopleForThisIntake = [intakePerson]; } else if (fallbackPeople.length > 0) { peopleForThisIntake = fallbackPeople; } else { peopleForThisIntake = [null]; } let timeBasedConsumed = 0; let lastAutoConsumedDateMs = 0; if (effectiveStart <= now.getTime()) { const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange( blister, effectiveStart, now.getTime() ); timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length; if (lastOccurrenceMs !== null) { lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs)); } } const stockCorrectionDateOnly = stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0; const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); let earlyTakenConsumed = 0; for (const doseId of takenDoseIds) { const parts = doseId.split("-"); if (parts.length < 3) continue; const bIdx = parseInt(parts[1], 10); const timestamp = parseInt(parts[2], 10); if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) { earlyTakenConsumed += blister.usage; } } consumedUntilNow += timeBasedConsumed + earlyTakenConsumed; }); } else { blisters.forEach((blister, blisterIdx) => { const blisterStart = parseLocalDateTime(blister.start); const blisterStartDateOnly = new Date( blisterStart.getFullYear(), blisterStart.getMonth(), blisterStart.getDate() ).getTime(); if (Number.isNaN(blisterStartDateOnly)) return; for (const doseId of takenDoseIds) { const parts = doseId.split("-"); if (parts.length < 3) continue; const parsedBlisterIdx = parseInt(parts[1], 10); const doseTimestamp = parseInt(parts[2], 10); if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) { continue; } const takenAt = takenDoseTimestamps.get(doseId) ?? 0; const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff; if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) { consumedUntilNow += blister.usage; } } }); } const currentStock = isTopical ? originalTotalPills : Math.max(0, originalTotalPills - consumedUntilNow); // Calculate usage for the planning period // Always use the user-selected start date for the usage calculation. // Using max(now, start) would cause asymmetric counting when now falls // between morning and evening doses on the start day (e.g., morning dose // skipped but evening counted), leading to confusing off-by-one results. // The stock already reflects consumed doses, so no double-counting occurs. // When includeUntilStart is true, calculate from now to end (useful for trip planning) const effectivePlannerStart = includeUntilStart ? now : start; const usageTotal = isTopical ? 0 : calculateUsageInRange(blisters, effectivePlannerStart, end); const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0; // Calculate AVAILABLE = stock AFTER the planned period (currentStock - usageTotal) const availableAfterPeriod = Math.max(0, currentStock - usageTotal); let fullBlisters: number; let loosePills: number; if (isAmountBasedPackageType(packageType)) { // Bottle type: no blisters, everything is loose pills fullBlisters = 0; loosePills = availableAfterPeriod; } else { // Blister type: calculate stock breakdown // Consumption order: loose pills first, then from blisters const totalConsumedByEnd = originalTotalPills - availableAfterPeriod; const looseConsumedByEnd = Math.min(totalConsumedByEnd, looseTablets); const loosePillsRemaining = Math.max(0, looseTablets - looseConsumedByEnd); const blisterPillsConsumed = totalConsumedByEnd - looseConsumedByEnd; const originalBlisterPills = originalTotalPills - looseTablets; const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed); fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0; const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0; loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose } const enough = currentStock >= usageTotal; return { medicationId: row.id, medicationName: row.name, totalPills: currentStock, currentPills: currentStock, plannerUsage: usageTotal, blisterSize: pillsPerBlister, blistersNeeded, fullBlisters, loosePills, enough, packageType, }; }); return payload; } ); // --------------------------------------------------------------------------- // POST /medications/dismiss-until - Set dismissedUntil date for multiple medications // This is more robust than storing individual dose IDs (which can change with schedule updates) // --------------------------------------------------------------------------- const dismissUntilSchema = z.object({ medicationIds: z.array(z.number().int().positive()).min(1), until: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"), }); app.post<{ Body: z.infer }>( "/medications/dismiss-until", { schema: { body: dismissUntilBodySchema, response: { 200: { type: "object", properties: { success: { type: "boolean" }, updatedCount: { type: "integer" }, }, }, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, }, }, }, async (req, reply) => { const parsed = dismissUntilSchema.safeParse(req.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" }); } const userId = await getUserId(req, reply); const { medicationIds, until } = parsed.data; // Update dismissedUntil for all specified medications owned by this user let updatedCount = 0; for (const medId of medicationIds) { const result = await db .update(medications) .set({ dismissedUntil: until }) .where(and(eq(medications.id, medId), eq(medications.userId, userId))); if (result.rowsAffected > 0) { updatedCount++; } } return { success: true, updatedCount }; } ); // --------------------------------------------------------------------------- // DELETE /medications/:id/dismiss-until - Clear dismissedUntil for a medication // --------------------------------------------------------------------------- app.delete<{ Params: { id: string } }>( "/medications/:id/dismiss-until", { schema: { params: idParamsSchema, response: { 200: successResponseSchema, 400: genericErrorSchema, 401: genericErrorSchema, }, }, }, async (req, reply) => { const medId = parseInt(req.params.id, 10); if (Number.isNaN(medId)) { return reply.status(400).send({ error: "Invalid medication ID" }); } const userId = await getUserId(req, reply); await db .update(medications) .set({ dismissedUntil: null }) .where(and(eq(medications.id, medId), eq(medications.userId, userId))); return { success: true }; } ); } function calculateUsageInRange( blisters: Array>, start: Date, end: Date ) { if (end.getTime() <= start.getTime()) { return 0; } let total = 0; blisters.forEach((blister) => { forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => { total += blister.usage; }); }); return Number(total.toFixed(2)); }