feat: extract dose tracking action service

* feat: extract dose tracking action service

* Use dose tracking service in protected routes

* Restore dose route compatibility
This commit is contained in:
Daniel Volz
2026-05-10 23:23:45 +02:00
committed by GitHub
parent b3c46ea179
commit 7df17ef705
3 changed files with 521 additions and 28 deletions
+15 -28
View File
@@ -6,6 +6,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import { dismissDosesForUser, markDoseTakenForUser } from "../services/dose-tracking-service.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
@@ -316,34 +317,22 @@ export async function doseRoutes(app: FastifyInstance) {
const { doseId } = parsed.data;
// Check if already marked
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
const result = await markDoseTakenForUser({
userId,
doseId,
source: "manual",
markedBy: null,
});
if (existing) {
if (!result.success) {
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
return reply.status(statusCode).send({ error: result.message, code: result.code });
}
if (result.status === "already_taken") {
return { success: true, message: "Already marked" };
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const outOfStock = await isDoseOutOfStock({
userId,
doseId,
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
});
if (outOfStock) {
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
// Insert new record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null, // Marked by the user themselves
takenSource: "manual",
});
return { success: true };
}
);
@@ -438,17 +427,16 @@ export async function doseRoutes(app: FastifyInstance) {
const { doseIds } = parsed.data;
// Insert dismissed records for each dose that doesn't exist yet
// Preserve the existing route semantics for dismiss: any non-dismissed record
// becomes dismissed, regardless of whether it already has a taken timestamp.
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists (taken or dismissed)
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing) {
// Already exists - update to dismissed if not already
if (!existing.dismissed) {
await db
.update(doseTracking)
@@ -457,7 +445,6 @@ export async function doseRoutes(app: FastifyInstance) {
dismissedCount++;
}
} else {
// Create new dismissed record
await db.insert(doseTracking).values({
userId,
doseId,
@@ -0,0 +1,280 @@
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,
};
}
@@ -0,0 +1,226 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
const { testClient, testDb } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
const { dismissDosesForUser, markDoseTakenForUser } = await import("../services/dose-tracking-service.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
const start = "2025-01-01T08:00:00.000Z";
await testClient.execute({
sql: `INSERT INTO medications (
id, user_id, name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
) VALUES (?, ?, 'Test Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 0)`,
args: [
options.id,
options.userId,
options.packCount ?? 1,
options.looseTablets ?? 0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: false }]),
],
});
}
async function insertUserSettings(userId: number, stockCalculationMode: "automatic" | "manual" = "automatic") {
await testClient.execute({
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, ?)",
args: [userId, stockCalculationMode],
});
}
async function insertDose(options: {
userId: number;
doseId: string;
dismissed?: boolean;
takenAt?: number;
takenSource?: "manual" | "automatic" | "notification";
markedBy?: string | null;
}) {
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed, taken_at, taken_source, marked_by)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
options.doseId,
options.dismissed ? 1 : 0,
options.takenAt ?? Math.floor(Date.now() / 1000),
options.takenSource ?? "manual",
options.markedBy ?? null,
],
});
}
describe("dose-tracking-service", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
});
afterAll(() => {
testClient.close();
});
beforeEach(async () => {
await clearTables();
});
it("inserts a taken row for a valid in-stock dose", async () => {
const userId = await createUser("dose-service-user");
await insertMedication({ id: 5, userId, packCount: 1 });
await insertUserSettings(userId, "automatic");
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "notification",
markedBy: null,
});
expect(result).toEqual({ success: true, status: "marked" });
const rows = await testClient.execute({
sql: "SELECT dismissed, taken_source, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, "5-0-1736064000000"],
});
expect(rows.rows).toEqual([
expect.objectContaining({ dismissed: 0, taken_source: "notification", marked_by: null }),
]);
});
it("is idempotent when the dose is already taken", async () => {
const userId = await createUser("dose-service-existing");
await insertDose({ userId, doseId: "5-0-1736064000000", dismissed: false });
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "manual",
markedBy: null,
});
expect(result).toEqual({ success: true, status: "already_taken" });
const count = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, "5-0-1736064000000"],
});
expect(Number(count.rows[0].count)).toBe(1);
});
it("rejects taking a dose that is already skipped", async () => {
const userId = await createUser("dose-service-dismissed");
await insertMedication({ id: 5, userId, packCount: 1 });
await insertUserSettings(userId, "automatic");
await insertDose({
userId,
doseId: "5-0-1736064000000",
dismissed: true,
takenAt: 0,
takenSource: "manual",
markedBy: null,
});
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "notification",
markedBy: "reminder",
});
expect(result).toEqual({ success: false, code: "ALREADY_SKIPPED", message: "Dose is already skipped" });
const rows = await testClient.execute({
sql: "SELECT dismissed, taken_source, marked_by, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, "5-0-1736064000000"],
});
expect(rows.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_source: "manual", marked_by: null })]);
expect(Number(rows.rows[0].taken_at)).toBe(0);
});
it("returns OUT_OF_STOCK without mutating dose tracking", async () => {
const userId = await createUser("dose-service-stock");
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
await insertUserSettings(userId, "automatic");
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "notification",
markedBy: null,
});
expect(result).toEqual({ success: false, code: "OUT_OF_STOCK", message: "Medication is out of stock" });
const count = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ?",
args: [userId],
});
expect(Number(count.rows[0].count)).toBe(0);
});
it("dismisses new doses, stays idempotent for dismissed rows, and preserves real taken rows", async () => {
const userId = await createUser("dose-service-dismiss");
await insertDose({ userId, doseId: "5-1-1736064000000", dismissed: true, takenAt: 0 });
await insertDose({ userId, doseId: "5-2-1736064000000", dismissed: false });
const result = await dismissDosesForUser({
userId,
doseIds: ["5-0-1736064000000", "5-1-1736064000000", "5-2-1736064000000"],
});
expect(result).toEqual({ success: true, dismissedCount: 1, alreadyTakenCount: 1 });
const rows = await testClient.execute({
sql: "SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
args: [userId],
});
expect(rows.rows).toEqual([
expect.objectContaining({ dose_id: "5-0-1736064000000", dismissed: 1, taken_at: 0 }),
expect.objectContaining({ dose_id: "5-1-1736064000000", dismissed: 1, taken_at: 0 }),
expect.objectContaining({ dose_id: "5-2-1736064000000", dismissed: 0 }),
]);
});
});