Files
medassist-ng/backend/src/routes/medications.ts
T

303 lines
11 KiB
TypeScript

import { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { createWriteStream, existsSync, unlinkSync } from "fs";
import { resolve, extname } from "path";
import { pipeline } from "stream/promises";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
const sliceSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime(),
});
const medicationSchema = z.object({
name: z.string().trim().min(1).max(100),
genericName: z.string().trim().max(100).nullable().optional(),
packCount: z.number().int().min(0).default(1),
stripsPerPack: z.number().int().min(1).default(1),
tabsPerStrip: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
expiryDate: z.string().nullable().optional(),
notes: z.string().max(500).nullable().optional(),
// count will be derived on the backend
slices: z.array(sliceSchema).min(1).max(12),
});
function zipSlices(usage: number[], every: number[], start: string[]) {
const len = Math.min(usage.length, every.length, start.length);
const slices: Array<{ usage: number; every: number; start: string }> = [];
for (let i = 0; i < len; i++) {
slices.push({ usage: usage[i], every: every[i], start: start[i] });
}
return slices;
}
function parseSlices(row: typeof medications.$inferSelect) {
try {
const usage = JSON.parse(row.usageJson) as number[];
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
return zipSlices(usage, every, start);
} catch (err) {
return [];
}
}
export async function medicationRoutes(app: FastifyInstance) {
app.get("/medications", async () => {
const rows = await db.select().from(medications).orderBy(medications.id);
return rows.map((row) => ({
id: row.id,
name: row.name,
genericName: row.genericName,
count: row.count,
strips: row.strips,
stripSize: row.stripSize,
packCount: row.packCount ?? 1,
stripsPerPack: row.stripsPerPack ?? row.strips ?? 1,
tabsPerStrip: row.tabsPerStrip ?? row.stripSize ?? 1,
looseTablets: row.looseTablets ?? 0,
slices: parseSlices(row),
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
notes: row.notes,
updatedAt: row.updatedAt,
}));
});
app.post("/medications", async (req, reply) => {
const parsed = medicationSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
const [inserted] = await db
.insert(medications)
.values({
name,
genericName: genericName || null,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
packCount,
stripsPerPack,
tabsPerStrip,
looseTablets,
expiryDate: expiryDate || null,
notes: notes || null,
usageJson,
everyJson,
startJson,
})
.returning();
return {
id: inserted.id,
name: inserted.name,
genericName: inserted.genericName,
count: inserted.count,
strips: inserted.strips,
stripSize: inserted.stripSize,
packCount: inserted.packCount,
stripsPerPack: inserted.stripsPerPack,
tabsPerStrip: inserted.tabsPerStrip,
looseTablets: inserted.looseTablets,
slices,
imageUrl: inserted.imageUrl,
expiryDate: inserted.expiryDate,
notes: inserted.notes,
updatedAt: inserted.updatedAt,
};
});
app.put<{ Params: { id: string } }>("/medications/:id", 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 { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
const result = await db
.update(medications)
.set({
name,
genericName: genericName || null,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
packCount,
stripsPerPack,
tabsPerStrip,
looseTablets,
expiryDate: expiryDate || null,
notes: notes || null,
usageJson,
everyJson,
startJson,
updatedAt: new Date(),
})
.where(eq(medications.id, idNum))
.returning();
if (!result.length) return reply.notFound();
return {
id: result[0].id,
name: result[0].name,
genericName: result[0].genericName,
count: result[0].count,
strips: result[0].strips,
stripSize: result[0].stripSize,
packCount: result[0].packCount,
stripsPerPack: result[0].stripsPerPack,
tabsPerStrip: result[0].tabsPerStrip,
looseTablets: result[0].looseTablets,
slices,
imageUrl: result[0].imageUrl,
expiryDate: result[0].expiryDate,
notes: result[0].notes,
updatedAt: result[0].updatedAt,
};
});
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
// Delete associated image if exists
const [existing] = await db.select().from(medications).where(eq(medications.id, idNum));
if (existing?.imageUrl) {
const imagePath = resolve(IMAGES_DIR, existing.imageUrl);
if (existsSync(imagePath)) unlinkSync(imagePath);
}
const deleted = await db.delete(medications).where(eq(medications.id, idNum)).returning();
if (!deleted.length) return reply.notFound();
return reply.status(204).send();
});
// Upload medication image
app.post<{ Params: { id: string } }>("/medications/:id/image", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const [existing] = await db.select().from(medications).where(eq(medications.id, idNum));
if (!existing) return reply.notFound();
const data = await req.file();
if (!data) return reply.badRequest("No file uploaded");
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(data.mimetype)) {
return reply.badRequest("Invalid file type. Allowed: JPEG, PNG, WebP, GIF");
}
const ext = extname(data.filename) || ".jpg";
const filename = `med-${idNum}-${Date.now()}${ext}`;
const filepath = resolve(IMAGES_DIR, filename);
await pipeline(data.file, createWriteStream(filepath));
// Delete old image if exists
if (existing.imageUrl) {
const oldPath = resolve(IMAGES_DIR, existing.imageUrl);
if (existsSync(oldPath)) unlinkSync(oldPath);
}
await db.update(medications).set({ imageUrl: filename, updatedAt: new Date() }).where(eq(medications.id, idNum));
return { success: true, imageUrl: filename };
});
// Delete medication image
app.delete<{ Params: { id: string } }>("/medications/:id/image", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const [existing] = await db.select().from(medications).where(eq(medications.id, idNum));
if (!existing) return reply.notFound();
if (existing.imageUrl) {
const filepath = resolve(IMAGES_DIR, existing.imageUrl);
if (existsSync(filepath)) unlinkSync(filepath);
}
await db.update(medications).set({ imageUrl: null, updatedAt: new Date() }).where(eq(medications.id, idNum));
return reply.status(204).send();
});
app.post("/medications/usage", async (req, reply) => {
const schema = z.object({ startDate: z.string().datetime(), endDate: z.string().datetime() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const { startDate, endDate } = 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 rows = await db.select().from(medications).orderBy(medications.id);
const payload = rows.map((row) => {
const slices = parseSlices(row);
const usageTotal = calculateUsageInRange(slices, start, end);
const tabsPerStrip = row.tabsPerStrip ?? row.stripSize ?? 1;
const packCount = row.packCount ?? 1;
const stripsPerPack = row.stripsPerPack ?? row.strips ?? 1;
const looseTablets = row.looseTablets ?? 0;
const stripsNeeded = tabsPerStrip > 0 ? Math.ceil(usageTotal / tabsPerStrip) : 0;
const stripsAvailable = packCount * stripsPerPack + (tabsPerStrip > 0 ? looseTablets / tabsPerStrip : 0);
const enough = stripsAvailable >= stripsNeeded;
return {
medicationId: row.id,
medicationName: row.name,
plannerUsage: usageTotal,
stripSize: tabsPerStrip,
stripsNeeded,
stripsAvailable,
enough,
};
});
return payload;
});
}
function calculateUsageInRange(slices: Array<{ usage: number; every: number; start: string }>, start: Date, end: Date) {
let total = 0;
slices.forEach((slice) => {
const sliceStart = new Date(slice.start);
if (Number.isNaN(sliceStart.getTime())) return;
// iterate occurrences from sliceStart up to end
for (let dt = new Date(sliceStart); dt < end; dt.setDate(dt.getDate() + slice.every)) {
if (dt >= start && dt < end) total += slice.usage;
}
});
return Number(total.toFixed(2));
}
function deriveTotalTablets(packCount: number, stripsPerPack: number, tabsPerStrip: number, looseTablets: number) {
const packs = packCount || 0;
const strips = stripsPerPack || 0;
const tabs = tabsPerStrip || 1;
const loose = looseTablets || 0;
const packed = packs * strips * tabs;
return packed + loose;
}