302 lines
9.4 KiB
TypeScript
302 lines
9.4 KiB
TypeScript
import { and, eq, gte, lt } from "drizzle-orm";
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import { z } from "zod";
|
|
import { db } from "../db/client.js";
|
|
import { doseTracking, 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,
|
|
validationErrorSchema,
|
|
} from "../utils/openapi-route-standards.js";
|
|
|
|
const reportDataSchema = z
|
|
.object({
|
|
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
|
startDate: z.string().datetime().optional(),
|
|
endDate: z.string().datetime().optional(),
|
|
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const hasStartDate = typeof value.startDate === "string";
|
|
const hasEndDate = typeof value.endDate === "string";
|
|
|
|
if (hasStartDate !== hasEndDate) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "startDate and endDate must be provided together",
|
|
path: hasStartDate ? ["endDate"] : ["startDate"],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!hasStartDate || !hasEndDate) {
|
|
return;
|
|
}
|
|
|
|
const startDateValue = value.startDate!;
|
|
const endDateValue = value.endDate!;
|
|
const startDate = new Date(startDateValue);
|
|
const endDate = new Date(endDateValue);
|
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid date range",
|
|
path: ["endDate"],
|
|
});
|
|
}
|
|
});
|
|
|
|
const reportDataBodyOpenApiSchema = {
|
|
type: "object",
|
|
required: ["medicationIds"],
|
|
properties: {
|
|
medicationIds: {
|
|
type: "array",
|
|
minItems: 1,
|
|
maxItems: 100,
|
|
items: { type: "integer", minimum: 1 },
|
|
},
|
|
startDate: {
|
|
type: "string",
|
|
format: "date-time",
|
|
},
|
|
endDate: {
|
|
type: "string",
|
|
format: "date-time",
|
|
},
|
|
takenByFilter: {
|
|
type: "array",
|
|
maxItems: 50,
|
|
items: { type: "string", minLength: 1, maxLength: 100 },
|
|
},
|
|
},
|
|
example: {
|
|
medicationIds: [1, 3, 5],
|
|
startDate: "2026-05-01T00:00:00.000Z",
|
|
endDate: "2026-06-01T00:00:00.000Z",
|
|
takenByFilter: ["Daniel"],
|
|
},
|
|
} as const;
|
|
|
|
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
|
|
|
function getPersonTagKey(value: string): string {
|
|
return value.trim().toLocaleLowerCase();
|
|
}
|
|
|
|
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
|
|
if (!takenByFilter) return true;
|
|
const parts = doseId.split("-");
|
|
if (parts.length < 4) return false;
|
|
const takenBy = parts.at(-1)?.trim();
|
|
if (!takenBy) return false;
|
|
return takenByFilter.has(getPersonTagKey(takenBy));
|
|
}
|
|
|
|
function getDoseScheduledAtMs(doseId: string): number | null {
|
|
const match = trackedDoseIdPattern.exec(doseId);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const scheduledAtMs = Number.parseInt(match[3], 10);
|
|
return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
|
|
}
|
|
|
|
function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
|
|
if (!range) {
|
|
return true;
|
|
}
|
|
|
|
if (timestampMs === null) {
|
|
return false;
|
|
}
|
|
|
|
return timestampMs >= range.startMs && timestampMs < range.endMs;
|
|
}
|
|
|
|
const reportDataResponseSchema = {
|
|
type: "object",
|
|
additionalProperties: {
|
|
type: "object",
|
|
properties: {
|
|
dosesTaken: { type: "integer" },
|
|
automaticDosesTaken: { type: "integer" },
|
|
dosesSkipped: { type: "integer" },
|
|
firstDoseAt: { type: "string" },
|
|
lastDoseAt: { type: "string" },
|
|
refills: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
packsAdded: { type: "integer" },
|
|
loosePillsAdded: { type: "integer" },
|
|
quantityAdded: { type: "integer" },
|
|
usedPrescription: { type: "boolean" },
|
|
refillDate: { type: "string", format: "date-time" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
export async function reportRoutes(app: FastifyInstance) {
|
|
app.addHook("preHandler", requireAuth);
|
|
applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true });
|
|
|
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
|
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/report-data - Get aggregated dose/refill data for report generation
|
|
app.post(
|
|
"/medications/report-data",
|
|
{
|
|
schema: {
|
|
body: reportDataBodyOpenApiSchema,
|
|
response: {
|
|
200: reportDataResponseSchema,
|
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
|
401: genericErrorSchema,
|
|
403: genericErrorSchema,
|
|
},
|
|
},
|
|
},
|
|
async (req, reply) => {
|
|
const parsed = reportDataSchema.safeParse(req.body);
|
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
|
|
|
const userId = await getUserId(req, reply);
|
|
const { medicationIds, startDate, endDate, takenByFilter } = parsed.data;
|
|
const normalizedTakenByFilter = takenByFilter?.length
|
|
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
|
|
: null;
|
|
const dateRange =
|
|
startDate && endDate
|
|
? {
|
|
startMs: new Date(startDate).getTime(),
|
|
endMs: new Date(endDate).getTime(),
|
|
}
|
|
: null;
|
|
|
|
// Verify all medications belong to this user
|
|
const userMeds = await db
|
|
.select({
|
|
id: medications.id,
|
|
packageType: medications.packageType,
|
|
blistersPerPack: medications.blistersPerPack,
|
|
pillsPerBlister: medications.pillsPerBlister,
|
|
})
|
|
.from(medications)
|
|
.where(eq(medications.userId, userId));
|
|
const medMap = new Map(userMeds.map((med) => [med.id, med]));
|
|
const userMedIds = new Set(userMeds.map((m) => m.id));
|
|
|
|
for (const id of medicationIds) {
|
|
if (!userMedIds.has(id)) {
|
|
return reply.status(403).send({ error: "Access denied to medication" });
|
|
}
|
|
}
|
|
|
|
// Fetch dose tracking for all requested medications
|
|
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
|
const allDoses = await db
|
|
.select({
|
|
doseId: doseTracking.doseId,
|
|
takenAt: doseTracking.takenAt,
|
|
dismissed: doseTracking.dismissed,
|
|
takenSource: doseTracking.takenSource,
|
|
})
|
|
.from(doseTracking)
|
|
.where(eq(doseTracking.userId, userId));
|
|
|
|
// Group doses by medication ID
|
|
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
|
for (const dose of allDoses) {
|
|
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
|
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
|
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
|
if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
|
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
|
dosesByMed.get(medId)!.push({
|
|
takenAt: dose.takenAt,
|
|
dismissed: dose.dismissed,
|
|
takenSource: dose.takenSource ?? "manual",
|
|
});
|
|
}
|
|
|
|
// Fetch refill history for requested medications
|
|
const result: Record<
|
|
number,
|
|
{
|
|
dosesTaken: number;
|
|
automaticDosesTaken: number;
|
|
dosesSkipped: number;
|
|
firstDoseAt: string | null;
|
|
lastDoseAt: string | null;
|
|
refills: {
|
|
packsAdded: number;
|
|
loosePillsAdded: number;
|
|
quantityAdded: number;
|
|
usedPrescription: boolean;
|
|
refillDate: string;
|
|
}[];
|
|
}
|
|
> = {};
|
|
|
|
for (const medId of medicationIds) {
|
|
const doses = dosesByMed.get(medId) ?? [];
|
|
const takenDoses = doses.filter((d) => !d.dismissed);
|
|
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
|
const skippedDoses = doses.filter((d) => d.dismissed);
|
|
|
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
|
const medication = medMap.get(medId);
|
|
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
|
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
|
|
|
// Get refills for this medication scoped to the authenticated user.
|
|
const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
|
|
if (dateRange) {
|
|
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
|
|
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
|
|
}
|
|
const refills = await db
|
|
.select()
|
|
.from(refillHistory)
|
|
.where(and(...refillFilters));
|
|
|
|
result[medId] = {
|
|
dosesTaken: takenDoses.length,
|
|
automaticDosesTaken: automaticTakenDoses.length,
|
|
dosesSkipped: skippedDoses.length,
|
|
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
|
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
|
refills: refills.map((r) => ({
|
|
packsAdded: r.packsAdded,
|
|
loosePillsAdded: r.loosePillsAdded,
|
|
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
|
usedPrescription: r.usedPrescription ?? false,
|
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
|
})),
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
);
|
|
}
|