From 7df17ef7056caad4abc3986de2364b0a589f23db Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 23:23:45 +0200 Subject: [PATCH] feat: extract dose tracking action service * feat: extract dose tracking action service * Use dose tracking service in protected routes * Restore dose route compatibility --- backend/src/routes/doses.ts | 43 +-- backend/src/services/dose-tracking-service.ts | 280 ++++++++++++++++++ .../src/test/dose-tracking-service.test.ts | 226 ++++++++++++++ 3 files changed, 521 insertions(+), 28 deletions(-) create mode 100644 backend/src/services/dose-tracking-service.ts create mode 100644 backend/src/test/dose-tracking-service.test.ts diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index 0048e49..7787c39 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -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, diff --git a/backend/src/services/dose-tracking-service.ts b/backend/src/services/dose-tracking-service.ts new file mode 100644 index 0000000..dcb14d7 --- /dev/null +++ b/backend/src/services/dose-tracking-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/backend/src/test/dose-tracking-service.test.ts b/backend/src/test/dose-tracking-service.test.ts new file mode 100644 index 0000000..f9e6452 --- /dev/null +++ b/backend/src/test/dose-tracking-service.test.ts @@ -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 }), + ]); + }); +});