Files
medassist-ng/backend/src/services/dose-tracking-service.ts
T
Daniel Volz 7df17ef705 feat: extract dose tracking action service
* feat: extract dose tracking action service

* Use dose tracking service in protected routes

* Restore dose route compatibility
2026-05-10 23:23:45 +02:00

281 lines
6.6 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { doseTracking, medications, userSettings } from "../db/schema.js";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import { computeMedicationCurrentStock } from "./current-stock.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
export type DoseTrackingSource = "manual" | "automatic" | "notification";
export type MarkDoseTakenResult =
| {
success: true;
status: "marked" | "already_taken";
}
| {
success: false;
code: "OUT_OF_STOCK" | "INVALID_DOSE" | "ALREADY_SKIPPED";
message: string;
};
export type DismissDosesResult = {
success: true;
dismissedCount: number;
alreadyTakenCount: number;
};
export type SkipDosesResult = {
success: true;
skippedCount: number;
alreadySkippedCount: number;
switchedFromTakenCount: number;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) {
return null;
}
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
function hasRealTakenTimestamp(takenAt: Date | null): boolean {
return takenAt instanceof Date && takenAt.getTime() > 0;
}
async function isDoseOutOfStock(options: { userId: number; doseId: string }): Promise<boolean> {
const parsedDose = parseDoseId(options.doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
if (!medication) {
return false;
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, options.userId));
const stockCalculationMode = (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic";
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
const intake = intakes[parsedDose.intakeIndex];
const scheduledOccurrenceMs = intake
? (() => {
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
).getTime();
})()
: parsedDose.timestampMs;
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
return (
computeMedicationCurrentStock({
medication,
doses,
stockCalculationMode,
nowMs: stockBeforeDoseMs,
}) <= 0
);
}
export async function markDoseTakenForUser(input: {
userId: number;
doseId: string;
source: DoseTrackingSource;
markedBy?: string | null;
}): Promise<MarkDoseTakenResult> {
const parsedDose = parseDoseId(input.doseId);
if (!parsedDose) {
return {
success: false,
code: "INVALID_DOSE",
message: "Invalid dose ID",
};
}
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (existing && !existing.dismissed) {
return { success: true, status: "already_taken" };
}
if (existing?.dismissed && hasRealTakenTimestamp(existing.takenAt)) {
return { success: true, status: "already_taken" };
}
if (existing?.dismissed) {
return {
success: false,
code: "ALREADY_SKIPPED",
message: "Dose is already skipped",
};
}
const outOfStock = await isDoseOutOfStock({ userId: input.userId, doseId: input.doseId });
if (outOfStock) {
return {
success: false,
code: "OUT_OF_STOCK",
message: "Medication is out of stock",
};
}
await db.insert(doseTracking).values({
userId: input.userId,
doseId: input.doseId,
takenAt: new Date(),
markedBy: input.markedBy ?? null,
takenSource: input.source,
dismissed: false,
});
return { success: true, status: "marked" };
}
export async function skipDosesForUser(input: { userId: number; doseIds: string[] }): Promise<SkipDosesResult> {
let skippedCount = 0;
let alreadySkippedCount = 0;
let switchedFromTakenCount = 0;
for (const doseId of input.doseIds) {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
if (!existing) {
await db.insert(doseTracking).values({
userId: input.userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
skippedCount++;
continue;
}
if (existing.dismissed) {
alreadySkippedCount++;
continue;
}
if (hasRealTakenTimestamp(existing.takenAt)) {
switchedFromTakenCount++;
}
await db
.update(doseTracking)
.set({
dismissed: true,
takenAt: new Date(0),
takenSource: "manual",
markedBy: null,
})
.where(eq(doseTracking.id, existing.id));
skippedCount++;
}
return {
success: true,
skippedCount,
alreadySkippedCount,
switchedFromTakenCount,
};
}
export async function dismissDosesForUser(input: { userId: number; doseIds: string[] }): Promise<DismissDosesResult> {
let dismissedCount = 0;
let alreadyTakenCount = 0;
for (const doseId of input.doseIds) {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
if (!existing) {
await db.insert(doseTracking).values({
userId: input.userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
dismissedCount++;
continue;
}
if (existing.dismissed) {
continue;
}
if (hasRealTakenTimestamp(existing.takenAt)) {
alreadyTakenCount++;
continue;
}
await db
.update(doseTracking)
.set({
dismissed: true,
takenAt: new Date(0),
takenSource: "manual",
markedBy: null,
})
.where(eq(doseTracking.id, existing.id));
dismissedCount++;
}
return {
success: true,
dismissedCount,
alreadyTakenCount,
};
}