e0fb77d494
Closes #424
426 lines
18 KiB
TypeScript
426 lines
18 KiB
TypeScript
/**
|
|
* Pure utility functions for database operations.
|
|
* Separated from client.ts to allow importing without triggering
|
|
* top-level database initialization side effects.
|
|
*/
|
|
|
|
import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { Client } from "@libsql/client";
|
|
import type { drizzle } from "drizzle-orm/libsql";
|
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
|
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
|
|
|
// Get migrations folder path (relative to this file's location)
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
|
|
|
// =============================================================================
|
|
// Path & Directory utilities
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Get the data directory path.
|
|
*
|
|
* Resolution order:
|
|
* 1. DATA_DIR env var (set by docker-compose for containers)
|
|
* 2. Monorepo detection: if ../docker-compose.yml exists, we're in backend/
|
|
* subdirectory → use ../data (project root's data folder)
|
|
* 3. Fallback: resolve(cwd, "data") (running from project root or standalone)
|
|
*/
|
|
export function getDataDir(cwd: string = process.cwd()): string {
|
|
// Docker containers set DATA_DIR explicitly
|
|
if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR);
|
|
|
|
// Local dev: detect if we're in backend/ subdirectory of the monorepo
|
|
if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) {
|
|
return resolve(cwd, "..", "data");
|
|
}
|
|
|
|
// Default: data/ relative to cwd (running from project root)
|
|
return resolve(cwd, "data");
|
|
}
|
|
|
|
/** Build the database URL from a path */
|
|
export function buildDbUrl(dbPath: string): string {
|
|
return `file:${dbPath}`;
|
|
}
|
|
|
|
/** Get data directory and database path */
|
|
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
|
const dataDir = getDataDir(cwd);
|
|
const dbPath = resolve(dataDir, "medassist-ng.db");
|
|
const url = buildDbUrl(dbPath);
|
|
return { dataDir, dbPath, url };
|
|
}
|
|
|
|
/** Ensure data directory exists and is writable */
|
|
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
|
try {
|
|
if (!existsSync(dataDir)) {
|
|
mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
|
|
// Check if directory is writable
|
|
accessSync(dataDir, constants.W_OK);
|
|
|
|
// Try to create a test file to verify write access
|
|
const testFile = resolve(dataDir, ".write-test");
|
|
writeFileSync(testFile, "test");
|
|
|
|
return { success: true };
|
|
} catch (err: unknown) {
|
|
return { success: false, error: (err as Error).message };
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Migration utilities
|
|
// =============================================================================
|
|
|
|
/** Run drizzle-kit migrations on the database */
|
|
export async function runDrizzleMigrations(
|
|
database: ReturnType<typeof drizzle>
|
|
): Promise<{ success: boolean; error?: string; warning?: string }> {
|
|
try {
|
|
await migrate(database, { migrationsFolder });
|
|
return { success: true };
|
|
} catch (err: unknown) {
|
|
const msg = (err as Error).message ?? "";
|
|
// Duplicate column / already exists = DB is already up-to-date (expected for existing DBs)
|
|
if (msg.includes("duplicate column") || msg.includes("already exists")) {
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: msg };
|
|
}
|
|
}
|
|
|
|
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
|
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
|
const errors: string[] = [];
|
|
|
|
// These add new columns to existing tables (silently fail if column already exists)
|
|
const alterMigrations = [
|
|
// Added in v1.x - repeat reminders and nagging settings
|
|
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
|
// Added for intake automation auditability (manual vs automatic taken)
|
|
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
|
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
|
// Added for stock correction - timestamp to ignore consumed doses before correction
|
|
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
|
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
|
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
|
// Added for soft-archiving medications (without deleting history)
|
|
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
|
// Added for explicit medication lifecycle start date
|
|
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
|
// Added for form/lifecycle modeling (V1 medication forms)
|
|
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
|
|
`ALTER TABLE medications ADD COLUMN pill_form text`,
|
|
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
|
|
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
|
|
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
|
|
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
|
|
// Added for more detailed reminder info display
|
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
|
// Added for package type support (blister vs bottle)
|
|
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
|
|
`ALTER TABLE medications ADD COLUMN total_pills integer`,
|
|
// Added for dose unit selection (mg, g, mcg, ml, IU, etc.)
|
|
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
|
|
// Added for intake-level takenBy: unified intakes structure
|
|
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
|
|
// Added for separate stock reminder tracking
|
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
|
// Added for share stock visibility toggle
|
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
|
// Added for integrated share overview visibility on shared links
|
|
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
|
// Added for timeline visibility toggles (dashboard + shared schedule)
|
|
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
|
// Added for prescription refill tracking and reminders
|
|
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
|
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
|
|
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
|
|
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
|
|
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
|
|
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
|
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
|
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
|
// Added for refill history prescription tracking
|
|
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
|
];
|
|
|
|
for (const sql of alterMigrations) {
|
|
try {
|
|
await client.execute(sql);
|
|
} catch (e: unknown) {
|
|
// Silently ignore "duplicate column" errors - column already exists
|
|
if (!(e as Error).message?.includes("duplicate column")) {
|
|
errors.push((e as Error).message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create tables that might be missing (silently fail if already exists)
|
|
const createTableMigrations = [
|
|
// Added in v1.3.x - refill history tracking
|
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
packs_added INTEGER NOT NULL DEFAULT 0,
|
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
)`,
|
|
// Added in v1.20.x - API key authentication for programmatic access
|
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
key_hash TEXT NOT NULL UNIQUE,
|
|
token_prefix TEXT NOT NULL DEFAULT '',
|
|
scope TEXT NOT NULL DEFAULT 'write',
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
last_used_at INTEGER,
|
|
expires_at INTEGER,
|
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
)`,
|
|
];
|
|
|
|
for (const sql of createTableMigrations) {
|
|
try {
|
|
await client.execute(sql);
|
|
} catch (e: unknown) {
|
|
// Silently ignore "table already exists" errors
|
|
if (!(e as Error).message?.includes("already exists")) {
|
|
errors.push((e as Error).message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create indexes that might be missing (silently fail if already exists)
|
|
const createIndexMigrations = [
|
|
// Added in v1.6.x - case-insensitive unique usernames
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
|
// Added in v1.20.x - fast API key lookup and ownership filtering
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
|
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
|
];
|
|
|
|
for (const sql of createIndexMigrations) {
|
|
try {
|
|
await client.execute(sql);
|
|
} catch (e: unknown) {
|
|
// Silently ignore "already exists" errors
|
|
if (!(e as Error).message?.includes("already exists")) {
|
|
errors.push((e as Error).message);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: errors.length === 0, errors };
|
|
}
|
|
|
|
// =============================================================================
|
|
// User utilities
|
|
// =============================================================================
|
|
|
|
/** Ensure default user exists for auth-disabled mode */
|
|
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
|
if (authEnabled) {
|
|
return false; // No default user needed
|
|
}
|
|
|
|
try {
|
|
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
|
if (result.rows.length === 0) {
|
|
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
|
|
return true; // Created
|
|
}
|
|
return false; // Already exists
|
|
} catch (e: unknown) {
|
|
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Startup repair: fix orphaned dose tracking IDs from past schedule changes
|
|
// =============================================================================
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
|
|
/**
|
|
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
|
|
* `[].toString()` produced an empty string, resulting in IDs like "5-0-1729123200000-"
|
|
* instead of "5-0-1729123200000". This strips trailing hyphens from all dose IDs.
|
|
*
|
|
* This function is idempotent - safe to run on every startup.
|
|
*/
|
|
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
|
const errors: string[] = [];
|
|
let repaired = 0;
|
|
|
|
try {
|
|
const result = await client.execute(
|
|
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
|
);
|
|
repaired = result.rowsAffected;
|
|
} catch (e: unknown) {
|
|
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
|
}
|
|
|
|
return { repaired, errors };
|
|
}
|
|
|
|
/**
|
|
* 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<number, Array<{ id: number; doseId: string }>>();
|
|
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<number, Set<number>>();
|
|
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<number>();
|
|
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: unknown) {
|
|
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e: unknown) {
|
|
errors.push(`Repair failed: ${(e as Error).message}`);
|
|
}
|
|
|
|
return { repaired, errors };
|
|
}
|