75bb7abebc
* feat: add stock correction modal with blister-based input - Add 'Correct Stock' button to medication detail modal - New modal with Full Blisters + Partial Blister Pills inputs - Auto-conversion for edge cases (full/negative partial) - New stockAdjustment field for DB corrections without touching looseTablets - New lastStockCorrectionAt timestamp to ignore old consumed doses after correction - Tracking data preserved for future statistics - Add Drizzle migrations for new columns - Add translations for en/de * fix: add stock_adjustment columns to e2e/integration test schemas
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { db } from "../db/client.js";
|
|
import { medications, doseTracking } from "../db/schema.js";
|
|
import { eq, and, like, sql } from "drizzle-orm";
|
|
import { createWriteStream, existsSync, unlinkSync } from "fs";
|
|
import { resolve, extname } from "path";
|
|
import { pipeline } from "stream/promises";
|
|
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
|
import { env } from "../plugins/env.js";
|
|
import type { AuthUser } from "../types/fastify.js";
|
|
|
|
const IMAGES_DIR = resolve(process.cwd(), "data/images");
|
|
|
|
const blisterSchema = 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(),
|
|
takenBy: z.array(z.string().trim().max(100)).default([]), // Array of person names
|
|
packCount: z.number().int().min(0).default(1),
|
|
blistersPerPack: z.number().int().min(1).default(1),
|
|
pillsPerBlister: z.number().int().min(1).default(1),
|
|
looseTablets: z.number().int().min(0).default(0),
|
|
pillWeightMg: z.number().int().min(1).nullable().optional(),
|
|
expiryDate: z.string().nullable().optional(),
|
|
notes: z.string().max(2000).nullable().optional(),
|
|
intakeRemindersEnabled: z.boolean().default(false),
|
|
blisters: z.array(blisterSchema).min(1).max(12),
|
|
});
|
|
|
|
function zipBlisters(usage: number[], every: number[], start: string[]) {
|
|
const len = Math.min(usage.length, every.length, start.length);
|
|
const blisters: Array<{ usage: number; every: number; start: string }> = [];
|
|
for (let i = 0; i < len; i++) {
|
|
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
|
}
|
|
return blisters;
|
|
}
|
|
|
|
function parseBlisters(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 zipBlisters(usage, every, start);
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
|
if (!takenByJson) return [];
|
|
try {
|
|
const parsed = JSON.parse(takenByJson);
|
|
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function medicationRoutes(app: FastifyInstance) {
|
|
// All medication routes require auth
|
|
app.addHook("preHandler", requireAuth);
|
|
|
|
// Helper to get user ID from request
|
|
// Returns anonymous user ID when auth is disabled
|
|
async function getUserId(request: any, reply: any): Promise<number> {
|
|
// 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("/medications", async (request, reply) => {
|
|
const userId = await getUserId(request, reply);
|
|
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
genericName: row.genericName,
|
|
takenBy: parseTakenByJson(row.takenByJson),
|
|
packCount: row.packCount ?? 1,
|
|
blistersPerPack: row.blistersPerPack ?? 1,
|
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
|
looseTablets: row.looseTablets ?? 0,
|
|
stockAdjustment: row.stockAdjustment ?? 0,
|
|
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
|
pillWeightMg: row.pillWeightMg,
|
|
blisters: parseBlisters(row),
|
|
imageUrl: row.imageUrl,
|
|
expiryDate: row.expiryDate,
|
|
notes: row.notes,
|
|
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
|
|
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 userId = await getUserId(req, reply);
|
|
const { name, genericName, takenBy, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
|
|
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
|
|
const everyJson = JSON.stringify(blisters.map((s) => s.every));
|
|
const startJson = JSON.stringify(blisters.map((s) => s.start));
|
|
const takenByJson = JSON.stringify(takenBy || []);
|
|
|
|
const [inserted] = await db
|
|
.insert(medications)
|
|
.values({
|
|
userId,
|
|
name,
|
|
genericName: genericName || null,
|
|
takenByJson,
|
|
packCount,
|
|
blistersPerPack,
|
|
pillsPerBlister,
|
|
looseTablets,
|
|
pillWeightMg: pillWeightMg || null,
|
|
expiryDate: expiryDate || null,
|
|
notes: notes || null,
|
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
|
usageJson,
|
|
everyJson,
|
|
startJson,
|
|
})
|
|
.returning();
|
|
|
|
return {
|
|
id: inserted.id,
|
|
name: inserted.name,
|
|
genericName: inserted.genericName,
|
|
takenBy: parseTakenByJson(inserted.takenByJson),
|
|
packCount: inserted.packCount,
|
|
blistersPerPack: inserted.blistersPerPack,
|
|
pillsPerBlister: inserted.pillsPerBlister,
|
|
looseTablets: inserted.looseTablets,
|
|
stockAdjustment: inserted.stockAdjustment ?? 0,
|
|
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
|
pillWeightMg: inserted.pillWeightMg,
|
|
blisters,
|
|
imageUrl: inserted.imageUrl,
|
|
expiryDate: inserted.expiryDate,
|
|
notes: inserted.notes,
|
|
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
|
|
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 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, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
|
|
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
|
|
const everyJson = JSON.stringify(blisters.map((s) => s.every));
|
|
const startJson = JSON.stringify(blisters.map((s) => s.start));
|
|
const takenByJson = JSON.stringify(takenBy || []);
|
|
|
|
const result = await db
|
|
.update(medications)
|
|
.set({
|
|
name,
|
|
genericName: genericName || null,
|
|
takenByJson,
|
|
packCount,
|
|
blistersPerPack,
|
|
pillsPerBlister,
|
|
looseTablets,
|
|
pillWeightMg: pillWeightMg || null,
|
|
expiryDate: expiryDate || null,
|
|
notes: notes || null,
|
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
|
usageJson,
|
|
everyJson,
|
|
startJson,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
|
.returning();
|
|
|
|
if (!result.length) return reply.notFound();
|
|
|
|
// Clean up dose tracking entries that are before the earliest start date
|
|
// This ensures consistency when the user changes the start date
|
|
const earliestStart = Math.min(...blisters.map(b => new Date(b.start).getTime()));
|
|
if (!Number.isNaN(earliestStart)) {
|
|
// Get all dose tracking entries for this medication and filter out invalid ones
|
|
const allDoses = await db.select().from(doseTracking)
|
|
.where(and(
|
|
eq(doseTracking.userId, userId),
|
|
like(doseTracking.doseId, `${idNum}-%`)
|
|
));
|
|
|
|
// Find doses with timestamps before the earliest start date
|
|
const dosesToDelete = allDoses.filter(dose => {
|
|
const parts = dose.doseId.split("-");
|
|
if (parts.length >= 3) {
|
|
const timestamp = parseInt(parts[2], 10);
|
|
return !Number.isNaN(timestamp) && timestamp < earliestStart;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
// Delete invalid doses
|
|
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),
|
|
packCount: result[0].packCount,
|
|
blistersPerPack: result[0].blistersPerPack,
|
|
pillsPerBlister: result[0].pillsPerBlister,
|
|
looseTablets: result[0].looseTablets,
|
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
|
pillWeightMg: result[0].pillWeightMg,
|
|
blisters,
|
|
imageUrl: result[0].imageUrl,
|
|
expiryDate: result[0].expiryDate,
|
|
notes: result[0].notes,
|
|
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
|
|
updatedAt: result[0].updatedAt,
|
|
};
|
|
});
|
|
|
|
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
|
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
|
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>("/medications/:id/stock-adjustment", 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 } = req.body as { stockAdjustment: number };
|
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
|
|
|
const result = await db
|
|
.update(medications)
|
|
.set({
|
|
stockAdjustment,
|
|
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
|
updatedAt: new Date(),
|
|
})
|
|
.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: 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");
|
|
|
|
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) {
|
|
const imagePath = resolve(IMAGES_DIR, existing.imageUrl);
|
|
if (existsSync(imagePath)) unlinkSync(imagePath);
|
|
}
|
|
|
|
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", 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.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(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", 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) {
|
|
const filepath = resolve(IMAGES_DIR, existing.imageUrl);
|
|
if (existsSync(filepath)) unlinkSync(filepath);
|
|
}
|
|
|
|
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", 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 userId = await getUserId(req, reply);
|
|
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
|
const now = new Date();
|
|
|
|
const payload = rows.map((row) => {
|
|
const blisters = parseBlisters(row);
|
|
const usageTotal = calculateUsageInRange(blisters, start, end);
|
|
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 originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
|
|
|
// Calculate consumption up to now (same logic as frontend)
|
|
let consumedUntilNow = 0;
|
|
blisters.forEach((blister) => {
|
|
const blisterStart = new Date(blister.start);
|
|
if (Number.isNaN(blisterStart.getTime()) || blisterStart > now) return;
|
|
const msPerDay = 86400000;
|
|
const period = Math.max(1, blister.every) * msPerDay;
|
|
const occurrences = Math.floor((now.getTime() - blisterStart.getTime()) / period) + 1;
|
|
consumedUntilNow += occurrences * blister.usage;
|
|
});
|
|
|
|
const currentPills = Math.max(0, originalTotalPills - consumedUntilNow);
|
|
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
|
|
|
|
// Calculate current stock using realistic consumption order (loose first, then blisters)
|
|
const consumed = originalTotalPills - currentPills;
|
|
const looseConsumed = Math.min(consumed, looseTablets);
|
|
const loosePillsRemaining = looseTablets - looseConsumed;
|
|
const blisterPillsConsumed = consumed - looseConsumed;
|
|
const originalBlisterPills = originalTotalPills - looseTablets;
|
|
const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed);
|
|
|
|
const fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0;
|
|
const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0;
|
|
const loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose
|
|
|
|
const enough = currentPills >= usageTotal;
|
|
return {
|
|
medicationId: row.id,
|
|
medicationName: row.name,
|
|
totalPills: currentPills,
|
|
plannerUsage: usageTotal,
|
|
blisterSize: pillsPerBlister,
|
|
blistersNeeded,
|
|
fullBlisters,
|
|
loosePills,
|
|
enough,
|
|
};
|
|
});
|
|
|
|
return payload;
|
|
});
|
|
}
|
|
|
|
function calculateUsageInRange(blisters: Array<{ usage: number; every: number; start: string }>, start: Date, end: Date) {
|
|
let total = 0;
|
|
blisters.forEach((blister) => {
|
|
const blisterStart = new Date(blister.start);
|
|
if (Number.isNaN(blisterStart.getTime())) return;
|
|
// iterate occurrences from blisterStart up to end
|
|
for (let dt = new Date(blisterStart); dt < end; dt.setDate(dt.getDate() + blister.every)) {
|
|
if (dt >= start && dt < end) total += blister.usage;
|
|
}
|
|
});
|
|
return Number(total.toFixed(2));
|
|
} |