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:
+15
-28
@@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user