571d94bf7e
## Package Type Feature - Add 'blister' and 'bottle' package types for medications - Bottle type uses totalPills for capacity and looseTablets for current stock - Blister type continues to use packCount/blistersPerPack/pillsPerBlister - Add doseUnit field for flexible dosing (mg, ml, IU, etc.) - Full UI support in medication form and detail modal ## Per-Intake TakenBy - Move takenBy from medication level to individual intakes - Each intake schedule can now be assigned to a different person - Update scheduler-utils to handle per-intake takenBy - Update SharedSchedule to filter by per-intake takenBy - Backward compatible with existing medication data ## UI Improvements - Add PasswordInput component with show/hide toggle - Centralize stockThresholds in AppContext for consistent status display - Fix SharedSchedule sync issues with per-intake takenBy - Improve mobile editing experience ## Technical - Add migrations 0004 and 0005 for schema changes - Update all relevant tests (1064 tests passing) - Maintain backward compatibility with ALTER migrations
231 lines
9.0 KiB
TypeScript
231 lines
9.0 KiB
TypeScript
import { accessSync, constants, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { type Client, createClient } from "@libsql/client";
|
|
import dotenv from "dotenv";
|
|
import { drizzle } from "drizzle-orm/libsql";
|
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
|
|
|
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
|
|
|
// 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");
|
|
|
|
// =============================================================================
|
|
// Exported utility functions for testing
|
|
// =============================================================================
|
|
|
|
/** 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 = resolve(cwd, "data");
|
|
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: any) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
/** 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: any) {
|
|
// If the error is "duplicate column", it means the schema is already up-to-date
|
|
// This happens when ALTER migrations in client.ts have already added the columns
|
|
// We consider this a success with a warning, not a failure
|
|
if (err.message?.includes("duplicate column")) {
|
|
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
|
}
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
/** 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 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 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 '[]'`,
|
|
];
|
|
|
|
for (const sql of alterMigrations) {
|
|
try {
|
|
await client.execute(sql);
|
|
} catch (e: any) {
|
|
// Silently ignore "duplicate column" errors - column already exists
|
|
if (!e.message?.includes("duplicate column")) {
|
|
errors.push(e.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'))
|
|
)`,
|
|
];
|
|
|
|
for (const sql of createTableMigrations) {
|
|
try {
|
|
await client.execute(sql);
|
|
} catch (e: any) {
|
|
// Silently ignore "table already exists" errors
|
|
if (!e.message?.includes("already exists")) {
|
|
errors.push(e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: errors.length === 0, errors };
|
|
}
|
|
|
|
/** 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: any) {
|
|
console.error(`[DB] Error creating default user:`, e.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Database initialization (runs on import)
|
|
// =============================================================================
|
|
|
|
// Use absolute path to ensure it works in Docker
|
|
const { dataDir, dbPath, url } = getDbPaths();
|
|
|
|
console.log(`[DB] Data directory: ${dataDir}`);
|
|
console.log(`[DB] Database path: ${dbPath}`);
|
|
console.log(`[DB] Database URL: ${url}`);
|
|
|
|
// Ensure data directory exists and is writable
|
|
const dirResult = ensureDataDirectory(dataDir);
|
|
if (!dirResult.success) {
|
|
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
|
|
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
|
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
|
process.exit(1);
|
|
} else {
|
|
console.log(`[DB] Data directory is writable`);
|
|
|
|
// Log directory stats
|
|
const stats = statSync(dataDir);
|
|
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
|
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
|
|
console.log(`[DB] Write test successful`);
|
|
}
|
|
|
|
let client: Client;
|
|
try {
|
|
client = createClient({ url });
|
|
console.log(`[DB] Database client created successfully`);
|
|
} catch (err: any) {
|
|
console.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
|
console.error(`[DB] Database path: ${dbPath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
export const db = drizzle(client);
|
|
|
|
// Auto-run migrations (self-healing database)
|
|
async function runMigrations() {
|
|
// Run drizzle-kit generated migrations
|
|
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
|
const migrateResult = await runDrizzleMigrations(db);
|
|
if (!migrateResult.success) {
|
|
console.error(`[DB] Migration error:`, migrateResult.error);
|
|
} else if (migrateResult.warning) {
|
|
console.log(`[DB] Migration warning:`, migrateResult.warning);
|
|
} else {
|
|
console.log(`[DB] Drizzle migrations completed`);
|
|
}
|
|
|
|
// Run ALTER TABLE migrations for backward compatibility
|
|
const alterResult = await runAlterMigrations(client);
|
|
if (alterResult.errors.length > 0) {
|
|
alterResult.errors.forEach((err) => console.error(`[DB] ALTER migration error:`, err));
|
|
}
|
|
console.log(`[DB] Tables verified/created`);
|
|
|
|
// If auth is disabled, ensure a default user exists (ID=1)
|
|
const authEnabled = process.env.AUTH_ENABLED === "true";
|
|
const created = await ensureDefaultUser(client, authEnabled);
|
|
if (created) {
|
|
console.log(`[DB] Created default user for auth-disabled mode`);
|
|
}
|
|
}
|
|
|
|
// Export promise so server can await it before starting
|
|
export const migrationsReady = runMigrations();
|