From 21127b38abe60e1f942fd5d4e5649afe21f5a7e4 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 6 Feb 2026 22:59:40 +0100 Subject: [PATCH] fix: repair orphaned dose tracking IDs on startup (#104) Add repairOrphanedDoseIds() function that runs during app startup (after ALTER migrations) to fix dose tracking entries that became invalid when medication schedules were changed before PR #103. The function: - Generates valid schedule dates for each medication's current intakes - Detects dose_tracking entries whose dateOnlyMs doesn't match any valid schedule date - Remaps orphaned doses to the nearest valid schedule date within half the intake interval - Preserves person suffixes in dose IDs - Is idempotent (safe to run on every startup) This complements PR #103 which only migrates dose IDs on future edits. The startup repair fixes existing broken data in production databases. Includes 8 tests covering: valid doses unchanged, 1-day shift repair, person suffix preservation, out-of-range detection, idempotency, multi-medication handling, and legacy format fallback. --- backend/src/db/client.ts | 146 ++++++++++++++++ backend/src/test/database.test.ts | 270 ++++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+) diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 9252001..c17bb0a 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -5,6 +5,7 @@ import { type Client, createClient } from "@libsql/client"; import dotenv from "dotenv"; import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; +import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); @@ -158,6 +159,142 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P } } +// ============================================================================= +// Startup repair: fix orphaned dose tracking IDs from past schedule changes +// ============================================================================= + +const MS_PER_DAY = 86_400_000; + +/** + * Repair orphaned dose tracking IDs that no longer match the current intake schedule. + * This fixes dose IDs that became invalid when a medication's schedule was changed + * BEFORE the on-edit migration (PR #103) was introduced. + * + * For each medication, generates all valid schedule dateOnlyMs values from each intake's + * start date up to today, then checks all dose_tracking entries. Any dose whose timestamp + * doesn't match a valid schedule date is remapped to the nearest valid date. + * + * This function is idempotent - safe to run on every startup. + */ +export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> { + const errors: string[] = []; + let repaired = 0; + + try { + // Get all medications + const medsResult = await client.execute( + "SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications" + ); + + if (medsResult.rows.length === 0) return { repaired, errors }; + + // Get all dose tracking entries + const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking"); + if (dosesResult.rows.length === 0) return { repaired, errors }; + + // Build a map of medId → dose entries for quick lookup + const dosesByMed = new Map>(); + for (const row of dosesResult.rows) { + const doseId = row.dose_id as string; + const parts = doseId.split("-"); + if (parts.length < 3) continue; + const medId = parseInt(parts[0], 10); + if (Number.isNaN(medId)) continue; + if (!dosesByMed.has(medId)) dosesByMed.set(medId, []); + dosesByMed.get(medId)!.push({ id: row.id as number, doseId }); + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + for (const med of medsResult.rows) { + const medId = med.id as number; + const medDoses = dosesByMed.get(medId); + if (!medDoses || medDoses.length === 0) continue; + + // Parse intakes + const intakes = parseIntakesJson( + med.intakes_json as string | null, + { + usageJson: (med.usage_json as string) || "[]", + everyJson: (med.every_json as string) || "[]", + startJson: (med.start_json as string) || "[]", + }, + (med.intake_reminders_enabled as number) === 1 + ); + + if (intakes.length === 0) continue; + + // For each intake index, build the set of valid dateOnlyMs values + const validDatesByIntake = new Map>(); + for (let idx = 0; idx < intakes.length; idx++) { + const intake = intakes[idx]; + const start = parseLocalDateTime(intake.start); + const every = intake.every; + if (every <= 0 || Number.isNaN(start.getTime())) continue; + + const validDates = new Set(); + for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) { + validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()); + } + validDatesByIntake.set(idx, validDates); + } + + // Check each dose entry + for (const dose of medDoses) { + const parts = dose.doseId.split("-"); + if (parts.length < 3) continue; + + const intakeIdx = parseInt(parts[1], 10); + const dateOnlyMs = parseInt(parts[2], 10); + if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue; + + const validDates = validDatesByIntake.get(intakeIdx); + if (!validDates) continue; // Unknown intake index - skip + + // Check if this dose's timestamp is valid + if (validDates.has(dateOnlyMs)) continue; // Already valid - nothing to do + + // Orphaned dose - find the nearest valid schedule date + const intake = intakes[intakeIdx]; + if (!intake) continue; + + const halfInterval = (intake.every * MS_PER_DAY) / 2; + let bestMatch: number | null = null; + let bestDist = Infinity; + + for (const validDate of validDates) { + const dist = Math.abs(validDate - dateOnlyMs); + if (dist < bestDist && dist <= halfInterval) { + bestDist = dist; + bestMatch = validDate; + } + } + + if (bestMatch !== null) { + // Rebuild dose ID with new timestamp, preserving person suffix + const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : ""; + const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`; + + try { + await client.execute({ + sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?", + args: [newDoseId, dose.id], + }); + repaired++; + } catch (e: any) { + errors.push(`Failed to repair dose ${dose.id}: ${e.message}`); + } + } + } + } + } catch (e: any) { + errors.push(`Repair failed: ${e.message}`); + } + + return { repaired, errors }; +} + // ============================================================================= // Database initialization (runs on import) // ============================================================================= @@ -218,6 +355,15 @@ async function runMigrations() { } console.log(`[DB] Tables verified/created`); + // Repair orphaned dose tracking IDs from past schedule changes + const repairResult = await repairOrphanedDoseIds(client); + if (repairResult.repaired > 0) { + console.log(`[DB] Repaired ${repairResult.repaired} orphaned dose tracking IDs`); + } + if (repairResult.errors.length > 0) { + repairResult.errors.forEach((err) => console.error(`[DB] Dose repair error:`, err)); + } + // If auth is disabled, ensure a default user exists (ID=1) const authEnabled = process.env.AUTH_ENABLED === "true"; const created = await ensureDefaultUser(client, authEnabled); diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts index 92706f8..3b6e3a5 100644 --- a/backend/src/test/database.test.ts +++ b/backend/src/test/database.test.ts @@ -13,6 +13,7 @@ import { ensureDataDirectory, ensureDefaultUser, getDbPaths, + repairOrphanedDoseIds, runAlterMigrations, runDrizzleMigrations, } from "../db/client.js"; @@ -620,4 +621,273 @@ describe("Database Client", () => { expect(users.rows).toHaveLength(1); }); }); + + describe("repairOrphanedDoseIds", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); + // Create a test user + await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'testuser', 'local')"); + }); + + it("should return 0 repairs when no data exists", async () => { + const result = await repairOrphanedDoseIds(client); + expect(result.repaired).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + it("should not modify dose IDs that already match the current schedule", async () => { + // Create weekly medication starting Oct 17 (Friday) + const intakes = JSON.stringify([ + { usage: 1, every: 7, start: "2025-10-17T08:00:00", takenBy: null, intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (1, 1, 'Weekly Med', ?, '[1]', '[7]', '["2025-10-17T08:00:00"]')`, + args: [intakes], + }); + + // Insert dose IDs that match the schedule (Fridays) + const fri17 = new Date(2025, 9, 17).getTime(); + const fri24 = new Date(2025, 9, 24).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri17}`], + }); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri24}`], + }); + + const result = await repairOrphanedDoseIds(client); + expect(result.repaired).toBe(0); + + // Verify IDs unchanged + const doses = await client.execute("SELECT dose_id FROM dose_tracking ORDER BY dose_id"); + expect(doses.rows[0].dose_id).toBe(`1-0-${fri17}`); + expect(doses.rows[1].dose_id).toBe(`1-0-${fri24}`); + }); + + it("should repair orphaned dose IDs when schedule shifted by 1 day", async () => { + // Current schedule: Saturdays (Oct 18) + const intakes = JSON.stringify([ + { usage: 1, every: 7, start: "2025-10-18T08:00:00", takenBy: null, intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (1, 1, 'Weekly Med', ?, '[1]', '[7]', '["2025-10-18T08:00:00"]')`, + args: [intakes], + }); + + // Insert orphaned dose IDs from OLD schedule (Fridays) + const fri17 = new Date(2025, 9, 17).getTime(); + const fri24 = new Date(2025, 9, 24).getTime(); + const fri31 = new Date(2025, 9, 31).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri17}`], + }); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri24}`], + }); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri31}`], + }); + + const result = await repairOrphanedDoseIds(client); + expect(result.repaired).toBe(3); + expect(result.errors).toHaveLength(0); + + // Verify dose IDs are now Saturdays + const sat18 = new Date(2025, 9, 18).getTime(); + const sat25 = new Date(2025, 9, 25).getTime(); + const nov1 = new Date(2025, 10, 1).getTime(); + + const doses = await client.execute("SELECT dose_id FROM dose_tracking ORDER BY dose_id"); + const ids = doses.rows.map((r) => r.dose_id); + expect(ids).toContain(`1-0-${sat18}`); + expect(ids).toContain(`1-0-${sat25}`); + expect(ids).toContain(`1-0-${nov1}`); + }); + + it("should preserve person suffix when repairing dose IDs", async () => { + // Current schedule: Saturdays + const intakes = JSON.stringify([ + { usage: 1, every: 7, start: "2025-10-18T08:00:00", takenBy: "Alice", intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (1, 1, 'Person Med', ?, '[1]', '[7]', '["2025-10-18T08:00:00"]')`, + args: [intakes], + }); + + // Orphaned dose with person suffix (from old Friday schedule) + const fri17 = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri17}-Alice`], + }); + + const result = await repairOrphanedDoseIds(client); + expect(result.repaired).toBe(1); + + // Verify person suffix preserved + const sat18 = new Date(2025, 9, 18).getTime(); + const doses = await client.execute("SELECT dose_id FROM dose_tracking"); + expect(doses.rows[0].dose_id).toBe(`1-0-${sat18}-Alice`); + }); + + it("should not repair doses that are too far from any valid schedule date", async () => { + // Current schedule: biweekly (every 14 days) starting Oct 18 + // halfInterval = 7 days, so doses more than 7 days from any valid date won't match + const intakes = JSON.stringify([ + { usage: 1, every: 14, start: "2025-10-18T08:00:00", takenBy: null, intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (1, 1, 'Biweekly Med', ?, '[1]', '[14]', '["2025-10-18T08:00:00"]')`, + args: [intakes], + }); + + // Insert dose on Oct 27 (9 days away from Oct 18, 4 days away from Nov 1) + // halfInterval = 7 days. Oct 27 is 9 days from Oct 18 (too far) and 4 days from Nov 1 (within range) + // Actually use Oct 26 which is 8 days from both (Oct 18 and Nov 1) - exactly at halfInterval + 1 + // Wait: biweekly = Oct 18, Nov 1. Oct 26 is 8 days from Oct 18, 6 days from Nov 1 → 6 < 7, matches Nov 1 + // Use Oct 25: 7 days from Oct 18, 7 days from Nov 1 → exactly at boundary. Use Oct 25 and check. + // The condition is dist <= halfInterval, so 7 <= 7 is true. Need dist > 7. + // Use a 28-day schedule instead: Oct 18, Nov 15. Midpoint is Nov 1-2. Nov 2 is 15 days from Oct 18, 13 from Nov 15. Both > 14. No match. + const intakes28 = JSON.stringify([ + { usage: 1, every: 28, start: "2025-10-18T08:00:00", takenBy: null, intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `UPDATE medications SET intakes_json = ?, every_json = '[28]' WHERE id = 1`, + args: [intakes28], + }); + + // Insert dose on Nov 2 (15 days from Oct 18, 13 days from Nov 15) + // halfInterval = 14 days. Both 15 > 14 and 13 < 14, so Nov 2 actually WOULD map to Nov 15. + // Use Nov 4: 17 days from Oct 18, 11 days from Nov 15 → 11 < 14, maps to Nov 15. + // For a 28-day interval, halfInterval = 14. A date must be > 14 days from ALL schedule dates. + // Between Oct 18 and Nov 15 (28 days), the only date > 14 from both is impossible. + // So lets use a gap: Oct 18 is the only past date for a monthly schedule. + // If we pick a date before Oct 18, like Oct 1 (17 days before Oct 18) → 17 > 14 → no match! + const oct1 = new Date(2025, 9, 1).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${oct1}`], + }); + + const result = await repairOrphanedDoseIds(client); + expect(result.repaired).toBe(0); + + // Dose should remain unchanged + const doses = await client.execute("SELECT dose_id FROM dose_tracking"); + expect(doses.rows[0].dose_id).toBe(`1-0-${oct1}`); + }); + + it("should be idempotent - running twice produces same result", async () => { + // Current schedule: Saturdays + const intakes = JSON.stringify([ + { usage: 1, every: 7, start: "2025-10-18T08:00:00", takenBy: null, intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (1, 1, 'Weekly Med', ?, '[1]', '[7]', '["2025-10-18T08:00:00"]')`, + args: [intakes], + }); + + // Insert orphaned dose from Friday + const fri17 = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri17}`], + }); + + // First run + const result1 = await repairOrphanedDoseIds(client); + expect(result1.repaired).toBe(1); + + // Second run - should find 0 repairs (already fixed) + const result2 = await repairOrphanedDoseIds(client); + expect(result2.repaired).toBe(0); + + // Verify final state + const sat18 = new Date(2025, 9, 18).getTime(); + const doses = await client.execute("SELECT dose_id FROM dose_tracking"); + expect(doses.rows).toHaveLength(1); + expect(doses.rows[0].dose_id).toBe(`1-0-${sat18}`); + }); + + it("should handle multiple medications independently", async () => { + // Med 1: weekly Saturdays + const intakes1 = JSON.stringify([ + { usage: 1, every: 7, start: "2025-10-18T08:00:00", takenBy: null, intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (1, 1, 'Med 1', ?, '[1]', '[7]', '["2025-10-18T08:00:00"]')`, + args: [intakes1], + }); + + // Med 2: daily starting Oct 20 (valid IDs, no repair needed) + const intakes2 = JSON.stringify([ + { usage: 1, every: 1, start: "2025-10-20T08:00:00", takenBy: null, intakeRemindersEnabled: false }, + ]); + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (2, 1, 'Med 2', ?, '[1]', '[1]', '["2025-10-20T08:00:00"]')`, + args: [intakes2], + }); + + // Med 1: orphaned Friday dose + const fri17 = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri17}`], + }); + + // Med 2: valid daily dose + const oct20 = new Date(2025, 9, 20).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`2-0-${oct20}`], + }); + + const result = await repairOrphanedDoseIds(client); + expect(result.repaired).toBe(1); // Only med 1 dose repaired + + // Med 2 dose should be unchanged + const med2Doses = await client.execute("SELECT dose_id FROM dose_tracking WHERE dose_id LIKE '2-%'"); + expect(med2Doses.rows[0].dose_id).toBe(`2-0-${oct20}`); + }); + + it("should handle legacy format (no intakes_json, uses usage/every/start arrays)", async () => { + // Medication with only legacy fields (intakes_json is '[]') + await client.execute({ + sql: `INSERT INTO medications (id, user_id, name, intakes_json, usage_json, every_json, start_json) + VALUES (1, 1, 'Legacy Med', '[]', '[1]', '[7]', '["2025-10-18T08:00:00"]')`, + args: [], + }); + + // Orphaned Friday dose + const fri17 = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${fri17}`], + }); + + const result = await repairOrphanedDoseIds(client); + expect(result.repaired).toBe(1); + + // Verify mapped to Saturday + const sat18 = new Date(2025, 9, 18).getTime(); + const doses = await client.execute("SELECT dose_id FROM dose_tracking"); + expect(doses.rows[0].dose_id).toBe(`1-0-${sat18}`); + }); + }); });