diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 94e7e15..0340049 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -3,16 +3,10 @@ import { type Client, createClient } from "@libsql/client"; import dotenv from "dotenv"; import { drizzle } from "drizzle-orm/libsql"; import { log } from "../utils/logger.js"; -// Import utilities from db-utils (side-effect-free) -import { - ensureDataDirectory, - ensureDefaultUser, - getDbPaths, - repairOrphanedDoseIds, - repairTrailingHyphenDoseIds, - runAlterMigrations, - runDrizzleMigrations, -} from "./db-utils.js"; +import { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js"; +// Import utilities from focused DB modules (side-effect-free) +import { ensureDataDirectory, getDbPaths } from "./path-utils.js"; +import { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js"; // Re-export all utilities so existing imports from client.ts keep working export { diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index d130a3a..4f00537 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -1,431 +1,12 @@ /** - * Pure utility functions for database operations. - * Separated from client.ts to allow importing without triggering - * top-level database initialization side effects. + * Compatibility barrel for DB utilities. + * + * New code should prefer importing from focused modules: + * - ./path-utils.js + * - ./migration-utils.js + * - ./repair-utils.js */ -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 { - forEachScheduledOccurrenceInRange, - getDateOnlyTimestamp, - getScheduleMatchWindowMs, - 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 -): 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 { - 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>(); - 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(); - forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => { - validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs))); - }); - 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 = getScheduleMatchWindowMs(intake); - 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 }; -} +export { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js"; +export { buildDbUrl, ensureDataDirectory, getDataDir, getDbPaths } from "./path-utils.js"; +export { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js"; diff --git a/backend/src/db/migration-utils.ts b/backend/src/db/migration-utils.ts new file mode 100644 index 0000000..93988a0 --- /dev/null +++ b/backend/src/db/migration-utils.ts @@ -0,0 +1,159 @@ +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"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + +/** Run drizzle-kit migrations on the database */ +export async function runDrizzleMigrations( + database: ReturnType +): Promise<{ success: boolean; error?: string; warning?: string }> { + try { + await migrate(database, { migrationsFolder }); + return { success: true }; + } catch (err: unknown) { + const msg = (err as Error).message ?? ""; + 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[] = []; + + const alterMigrations = [ + `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`, + `ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`, + `ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`, + `ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`, + `ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`, + `ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`, + `ALTER TABLE medications ADD COLUMN dismissed_until text`, + `ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`, + `ALTER TABLE medications ADD COLUMN obsolete_at integer`, + `ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`, + `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'`, + `ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`, + `ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`, + `ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`, + `ALTER TABLE medications ADD COLUMN total_pills integer`, + `ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`, + `ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`, + `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`, + `ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`, + `ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`, + `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`, + `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`, + `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) { + if (!(e as Error).message?.includes("duplicate column")) { + errors.push((e as Error).message); + } + } + } + + const createTableMigrations = [ + `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')) + )`, + `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) { + if (!(e as Error).message?.includes("already exists")) { + errors.push((e as Error).message); + } + } + } + + const createIndexMigrations = [ + `CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`, + `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) { + if (!(e as Error).message?.includes("already exists")) { + errors.push((e as Error).message); + } + } + } + + return { success: errors.length === 0, errors }; +} + +/** Ensure default user exists for auth-disabled mode */ +export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise { + if (authEnabled) { + return false; + } + + 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; + } + return false; + } catch (e: unknown) { + console.error(`[DB] Error creating default user:`, (e as Error).message); + return false; + } +} diff --git a/backend/src/db/path-utils.ts b/backend/src/db/path-utils.ts new file mode 100644 index 0000000..7085a34 --- /dev/null +++ b/backend/src/db/path-utils.ts @@ -0,0 +1,48 @@ +import { accessSync, constants, existsSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * 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 { + if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR); + + if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) { + return resolve(cwd, "..", "data"); + } + + 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 }); + } + + accessSync(dataDir, constants.W_OK); + return { success: true }; + } catch (err: unknown) { + return { success: false, error: (err as Error).message }; + } +} diff --git a/backend/src/db/repair-utils.ts b/backend/src/db/repair-utils.ts new file mode 100644 index 0000000..4acaa2c --- /dev/null +++ b/backend/src/db/repair-utils.ts @@ -0,0 +1,141 @@ +import type { Client } from "@libsql/client"; +import { + forEachScheduledOccurrenceInRange, + getDateOnlyTimestamp, + getScheduleMatchWindowMs, + parseIntakesJson, + parseLocalDateTime, +} from "../utils/scheduler-utils.js"; + +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. + */ +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. + */ +export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> { + const errors: string[] = []; + let repaired = 0; + + try { + 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 }; + + const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking"); + if (dosesResult.rows.length === 0) return { repaired, errors }; + + 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; + + 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; + + 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(); + forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => { + validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs))); + }); + validDatesByIntake.set(idx, validDates); + } + + 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 || validDates.has(dateOnlyMs)) continue; + + const intake = intakes[intakeIdx]; + if (!intake) continue; + + const halfInterval = getScheduleMatchWindowMs(intake); + 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) { + 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 }; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index e155f44..ba36ada 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,7 +30,7 @@ import { reportRoutes } from "./routes/report.js"; import { settingsRoutes } from "./routes/settings.js"; import { shareRoutes } from "./routes/share.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; -import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment.js"; +import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment/index.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js"; diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index a493bc9..44e8435 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -3,6 +3,7 @@ import { and, count, eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { db } from "../db/client.js"; import { apiKeys, users } from "../db/schema.js"; +import { log } from "../utils/logger.js"; import { env } from "./env.js"; // ============================================================================= @@ -180,8 +181,14 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply .select() .from(apiKeys) .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))); - if (!keyRow) return; - if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) return; + if (!keyRow) { + log.debug("[Auth] optionalAuth API key verification failed: key not found"); + return; + } + if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) { + log.debug("[Auth] optionalAuth API key verification failed: key expired"); + return; + } const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId)); if (userByKey?.isActive) { @@ -191,7 +198,10 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply scope: keyRow.scope === "read" ? "read" : "write", apiKeyId: keyRow.id, }; + log.debug("[Auth] optionalAuth authenticated via API key"); + return; } + log.debug("[Auth] optionalAuth API key verification failed: user inactive or missing"); return; } @@ -212,9 +222,11 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply method: "session", scope: "write", }; + log.debug("[Auth] optionalAuth authenticated via session token"); } - } catch { - // Invalid token, continue as anonymous + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.debug(`[Auth] optionalAuth session verification failed: ${errorMessage}`); } } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 7a031cc..a7f6b6d 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -5,7 +5,7 @@ import { eq, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; -import { getDataDir } from "../db/db-utils.js"; +import { getDataDir } from "../db/path-utils.js"; import { refreshTokens, users } from "../db/schema.js"; import { getAuthState, requireAuth } from "../plugins/auth.js"; import type { AuthUser } from "../types/fastify.js"; diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index ecbc733..4fced15 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -5,7 +5,7 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; -import { getDataDir } from "../db/db-utils.js"; +import { getDataDir } from "../db/path-utils.js"; import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; diff --git a/backend/src/routes/medication-enrichment.ts b/backend/src/routes/medication-enrichment.ts index 17e388d..b509114 100644 --- a/backend/src/routes/medication-enrichment.ts +++ b/backend/src/routes/medication-enrichment.ts @@ -8,7 +8,7 @@ import { type MedicationEnrichmentEnrichRequest, MedicationEnrichmentServiceError, searchMedicationEnrichment, -} from "../services/medication-enrichment.js"; +} from "../services/medication-enrichment/index.js"; import { applyOpenApiRouteStandards, genericErrorSchema, diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 1e74694..2331924 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -3,10 +3,11 @@ import { and, eq, like } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; -import { getDataDir } from "../db/db-utils.js"; +import { getDataDir } from "../db/path-utils.js"; import { doseTracking, medications, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +import { calculateUsageInRange, normalizeDateTime, parseIntakesWithUnits } from "../services/medications-service.js"; import type { AuthUser } from "../types/fastify.js"; import { ALLOWED_IMAGE_MIME_TYPES, @@ -37,70 +38,12 @@ import { type Intake, normalizeIntake, normalizeIntakeUsageForStock, - parseIntakesJson, parseLocalDateTime, parseTakenByJson, } from "../utils/scheduler-utils.js"; const IMAGES_DIR = resolve(getDataDir(), "images"); -function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" { - return value === "ml" || value === "tsp" || value === "tbsp"; -} - -function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> { - if (!intakesJson) return []; - try { - const parsed = JSON.parse(intakesJson); - if (!Array.isArray(parsed)) return []; - return parsed.map((item: unknown) => { - if (!item || typeof item !== "object") return null; - const unit = (item as Record).intakeUnit; - return isIntakeUnit(unit) ? unit : null; - }); - } catch { - return []; - } -} - -function parseIntakesWithUnits( - intakesJson: string | null | undefined, - legacyRow: { usageJson: string; everyJson: string; startJson: string }, - medicationIntakeRemindersEnabled?: boolean -): Intake[] { - const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled); - const rawUnits = parseRawIntakeUnits(intakesJson); - if (rawUnits.length === 0) return intakes; - - return intakes.map((intake, idx) => ({ - ...intake, - intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null, - })); -} - -function normalizeDateTime(value: unknown): string | null { - if (value == null) { - return null; - } - - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? null : value.toISOString(); - } - - if (typeof value === "number") { - const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value; - const date = new Date(timestampMs); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); - } - - if (typeof value === "string") { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); - } - - return null; -} - // New intake schema with per-intake takenBy const intakeSchema = z.object({ usage: z.number().nonnegative(), @@ -1765,21 +1708,3 @@ export async function medicationRoutes(app: FastifyInstance) { } ); } - -function calculateUsageInRange( - blisters: Array>, - start: Date, - end: Date -) { - if (end.getTime() <= start.getTime()) { - return 0; - } - - let total = 0; - blisters.forEach((blister) => { - forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => { - total += blister.usage; - }); - }); - return Number(total.toFixed(2)); -} diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index a0561b1..b9205dd 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -13,6 +13,14 @@ import { } from "../i18n/translations.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +import { + buildPrescriptionReminderPushNotification, + buildStockReminderPushNotification, + type PrescriptionReminderItem as SharedPrescriptionReminderItem, + type StockReminderItem as SharedStockReminderItem, +} from "../services/notifications/builders.js"; +import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js"; +import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js"; import type { AuthUser } from "../types/fastify.js"; import { @@ -20,56 +28,9 @@ import { genericErrorSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; -import { - getPlannerUnitKind, - isAmountBasedPackageType, - isTubePackageType, - normalizePackageType, -} from "../utils/package-profiles.js"; +import { isTubePackageType, normalizePackageType } from "../utils/package-profiles.js"; import { loadUserSettings, sendShoutrrrNotification } from "./settings.js"; -// Escape HTML to prevent XSS in email templates -function escapeHtml(text: string): string { - const htmlEscapes: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }; - return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char); -} - -type MailDeliveryInfo = { - accepted?: unknown; - rejected?: unknown; - response?: unknown; -}; - -function normalizeRecipients(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => (typeof entry === "string" ? entry : String(entry ?? ""))) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function getDeliveryError(info: MailDeliveryInfo): string | null { - const accepted = normalizeRecipients(info.accepted); - const rejected = normalizeRecipients(info.rejected); - - if (accepted.length > 0) return null; - if (rejected.length > 0) { - return `SMTP rejected all recipients: ${rejected.join(", ")}`; - } - - if (typeof info.response === "string" && info.response.trim()) { - return `SMTP did not confirm accepted recipients. Response: ${info.response}`; - } - - return "SMTP did not confirm accepted recipients."; -} - type PlannerRow = { medicationId: number; medicationName: string; @@ -83,17 +44,6 @@ type PlannerRow = { packageType?: string; }; -function isContainerPackage(packageType?: string): boolean { - return isAmountBasedPackageType(packageType); -} - -function getPlannerUnit(packageType: string | undefined, tr: ReturnType): string { - const unitKind = getPlannerUnitKind(packageType); - if (unitKind === "units") return tr.common.units; - if (unitKind === "ml") return tr.common.ml; - return tr.common.pills; -} - type SendEmailBody = { email: string; from: string; @@ -682,7 +632,6 @@ ${getFooterPlain(language)}`; if (lowStockMeds.length > 0) { titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); } - const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; // Build description text let descriptionText: string; @@ -723,28 +672,23 @@ ${getFooterPlain(language)}`; // Send email if enabled if (notificationSettings.emailEnabled && email) { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + const smtp = getSmtpConfig(); request.log.info( { userId, - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), - hasSmtpPass: Boolean(smtpPass), - smtpPort, - smtpSecure, - hasSmtpFrom: Boolean(smtpFrom), + hasSmtpHost: Boolean(smtp.host), + hasSmtpUser: Boolean(smtp.user), + hasSmtpPass: Boolean(smtp.pass), + smtpPort: smtp.port, + smtpSecure: smtp.secure, + hasSmtpFrom: Boolean(smtp.from), recipientEmail: email, }, "[ReminderManual] Stock email path selected" ); - if (smtpHost && smtpUser) { + if (smtp.host && smtp.user) { // Build subject line from shared title parts const subjectText = titleParts.join(", "); @@ -847,29 +791,18 @@ ${getFooterPlain(language)}`; const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); - request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending stock reminder email"); - const mailResult = await transporter.sendMail({ - from: smtpFrom, + const mailResult = await sendEmailNotification({ to: email, subject: `MedAssist-ng: ${subjectText}`, text: plainText, html, + from: smtp.from, }); - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); + if (!mailResult.success) { + throw new Error(mailResult.error ?? "Unknown error"); } request.log.info( @@ -886,8 +819,8 @@ ${getFooterPlain(language)}`; request.log.warn( { userId, - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), + hasSmtpHost: Boolean(smtp.host), + hasSmtpUser: Boolean(smtp.user), recipientEmail: email, }, "[ReminderManual] Stock reminder email skipped: SMTP not configured" @@ -902,13 +835,13 @@ ${getFooterPlain(language)}`; // Send push notification if enabled if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { - const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; + const pushPayload = buildStockReminderPushNotification(filteredLowStock as SharedStockReminderItem[], language); try { - const pushResult = await sendShoutrrrNotification( + const pushResult = await sendPushNotification( notificationSettings.shoutrrrUrl, - notificationTitle, - message + pushPayload.title, + pushPayload.message ); if (pushResult.success) { results.push = true; @@ -1046,39 +979,24 @@ ${getFooterPlain(language)}`; const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + const smtp = getSmtpConfig(); request.log.info( { userId, - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), - hasSmtpPass: Boolean(smtpPass), - smtpPort, - smtpSecure, - hasSmtpFrom: Boolean(smtpFrom), + hasSmtpHost: Boolean(smtp.host), + hasSmtpUser: Boolean(smtp.user), + hasSmtpPass: Boolean(smtp.pass), + smtpPort: smtp.port, + smtpSecure: smtp.secure, + hasSmtpFrom: Boolean(smtp.from), recipientEmail: email, }, "[ReminderManual] Prescription email path selected" ); - if (smtpHost && smtpUser) { + if (smtp.host && smtp.user) { try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); - const subject = filteredPrescriptionLow.length === 1 ? tr.prescriptionReminder.subjectSingle @@ -1152,17 +1070,16 @@ ${getFooterPlain(language)}`; request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email"); - const mailResult = await transporter.sendMail({ - from: smtpFrom, + const mailResult = await sendEmailNotification({ to: email, subject, text, html, + from: smtp.from, }); - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); + if (!mailResult.success) { + throw new Error(mailResult.error ?? "Unknown error"); } request.log.info( @@ -1182,8 +1099,8 @@ ${getFooterPlain(language)}`; request.log.warn( { userId, - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), + hasSmtpHost: Boolean(smtp.host), + hasSmtpUser: Boolean(smtp.user), recipientEmail: email, }, "[ReminderManual] Prescription reminder email skipped: SMTP not configured" @@ -1201,37 +1118,17 @@ ${getFooterPlain(language)}`; } if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) { - const titleParts: string[] = []; - if (emptyRx.length > 0) - titleParts.push( - `🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` - ); - if (lowRx.length > 0) - titleParts.push( - `🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` - ); - const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`; - - const messageParts: string[] = []; - if (emptyRx.length > 0) { - messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); - for (const m of emptyRx) { - messageParts.push(` • ${m.name}`); - } - } - if (lowRx.length > 0) { - if (emptyRx.length > 0) messageParts.push(""); - messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); - for (const m of lowRx) { - messageParts.push( - ` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}` - ); - } - } - const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; + const pushPayload = buildPrescriptionReminderPushNotification( + filteredPrescriptionLow as SharedPrescriptionReminderItem[], + language + ); try { - const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message); + const pushResult = await sendPushNotification( + userSettings.shoutrrrUrl, + pushPayload.title, + pushPayload.message + ); if (pushResult.success) { results.push = true; } else { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 99d7a6b..28bd238 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -3,51 +3,21 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import nodemailer from "nodemailer"; import { db } from "../db/client.js"; import { userSettings } from "../db/schema.js"; -import type { Language } from "../i18n/translations.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +import { + classifyTestEmailFailure, + getAllUserSettingsFromDb, + getDefaultSettings, + getNotificationProvider, + loadUserSettingsFromDb, + sanitizeNotificationUrl, + type UserSettings, + validateNotificationHostname, +} from "../services/settings-service.js"; import type { AuthUser } from "../types/fastify.js"; -// Exported type for use in schedulers -export type UserSettings = { - userId: number; - emailEnabled: boolean; - notificationEmail: string | null; - emailStockReminders: boolean; - emailIntakeReminders: boolean; - emailPrescriptionReminders: boolean; - shoutrrrEnabled: boolean; - shoutrrrUrl: string | null; - shoutrrrStockReminders: boolean; - shoutrrrIntakeReminders: boolean; - shoutrrrPrescriptionReminders: boolean; - reminderDaysBefore: number; - repeatDailyReminders: boolean; - skipRemindersForTakenDoses: boolean; - repeatRemindersEnabled: boolean; - reminderRepeatIntervalMinutes: number; - maxNaggingReminders: number; - lowStockDays: number; - normalStockDays: number; - highStockDays: number; - language: Language; - stockCalculationMode: "automatic" | "manual"; - shareMedicationOverview: boolean; - upcomingTodayOnly: boolean; - shareScheduleTodayOnly: boolean; - swapDashboardMainSections: boolean; - lastAutoEmailSent: string | null; - lastNotificationType: string | null; - lastNotificationChannel: string | null; - lastReminderMedName: string | null; - lastReminderTakenBy: string | null; - lastStockReminderSent: string | null; - lastStockReminderChannel: string | null; - lastStockReminderMedNames: string | null; - lastPrescriptionReminderSent: string | null; - lastPrescriptionReminderChannel: string | null; - lastPrescriptionReminderMedNames: string | null; -}; +export type { UserSettings } from "../services/settings-service.js"; type SettingsBody = { emailEnabled: boolean; @@ -127,61 +97,6 @@ function getDeliveryError(info: MailDeliveryInfo): string | null { return "SMTP did not confirm accepted recipients."; } -function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - const normalizedMessage = errorMessage.toLowerCase(); - - if ( - normalizedMessage.includes("smtp rejected all recipients") || - normalizedMessage.includes("all recipients were rejected") || - normalizedMessage.includes("recipient address rejected") || - normalizedMessage.includes("nullmx") - ) { - return { - status: 400, - code: "EMAIL_RECIPIENT_REJECTED", - message: `Failed to send email: ${errorMessage}`, - }; - } - - if (errorMessage.includes("SMTP did not confirm accepted recipients")) { - return { - status: 502, - code: "SMTP_DELIVERY_UNCONFIRMED", - message: `Failed to send email: ${errorMessage}`, - }; - } - - return { - status: 500, - code: "TEST_EMAIL_FAILED", - message: `Failed to send email: ${errorMessage}`, - }; -} - -function getNotificationProvider(url: string): string { - if (url.startsWith("discord://")) return "discord"; - if (url.startsWith("telegram://")) return "telegram"; - if (url.startsWith("gotify://")) return "gotify"; - if (url.startsWith("pushover://")) return "pushover"; - if (url.startsWith("ntfy://")) return "ntfy"; - - try { - const parsed = new URL(url); - return parsed.hostname || "https"; - } catch { - return "unknown"; - } -} - -// Helper to parse boolean env vars -function envBool(key: string, defaultVal: boolean): boolean { - const val = process.env[key]; - if (val === undefined) return defaultVal; - return val === "true" || val === "1"; -} - -// Helper to parse integer env vars function envInt(key: string, defaultVal: number): number { const val = process.env[key]; if (val === undefined) return defaultVal; @@ -189,54 +104,10 @@ function envInt(key: string, defaultVal: number): number { return Number.isNaN(parsed) ? defaultVal : parsed; } -// Default settings for new users - read from ENV with fallbacks -function getDefaultSettings() { - return { - emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false), - notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null, - emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true), - emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true), - emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true), - shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false), - shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null, - shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true), - shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true), - shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true), - reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7), - repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false), - skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false), - repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false), - reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30), - maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5), - lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30), - normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90), - highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180), - language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en", - stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic", - shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false), - upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false), - shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false), - swapDashboardMainSections: false, - lastAutoEmailSent: null, - lastNotificationType: null, - lastNotificationChannel: null, - lastReminderMedName: null, - lastReminderTakenBy: null, - lastStockReminderSent: null, - lastStockReminderChannel: null, - lastStockReminderMedNames: null, - lastPrescriptionReminderSent: null, - lastPrescriptionReminderChannel: null, - lastPrescriptionReminderMedNames: null, - }; -} - -// Helper to get or create user settings async function getOrCreateUserSettings(userId: number) { let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); if (!settings) { - // Create default settings for user (using ENV defaults) [settings] = await db .insert(userSettings) .values({ @@ -251,90 +122,12 @@ async function getOrCreateUserSettings(userId: number) { // Export for use in reminder scheduler export async function loadUserSettings(userId: number): Promise { - const settings = await getOrCreateUserSettings(userId); - return { - userId: settings.userId, - emailEnabled: settings.emailEnabled, - notificationEmail: settings.notificationEmail, - emailStockReminders: settings.emailStockReminders, - emailIntakeReminders: settings.emailIntakeReminders, - emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, - shoutrrrEnabled: settings.shoutrrrEnabled, - shoutrrrUrl: settings.shoutrrrUrl, - shoutrrrStockReminders: settings.shoutrrrStockReminders, - shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, - shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, - reminderDaysBefore: settings.reminderDaysBefore, - repeatDailyReminders: settings.repeatDailyReminders, - skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, - repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, - reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, - maxNaggingReminders: settings.maxNaggingReminders ?? 5, - lowStockDays: settings.lowStockDays, - normalStockDays: settings.normalStockDays, - highStockDays: settings.highStockDays, - language: settings.language as Language, - stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", - shareMedicationOverview: settings.shareMedicationOverview ?? false, - upcomingTodayOnly: settings.upcomingTodayOnly ?? false, - shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, - swapDashboardMainSections: settings.swapDashboardMainSections ?? false, - lastAutoEmailSent: settings.lastAutoEmailSent, - lastNotificationType: settings.lastNotificationType, - lastNotificationChannel: settings.lastNotificationChannel, - lastReminderMedName: settings.lastReminderMedName ?? null, - lastReminderTakenBy: settings.lastReminderTakenBy ?? null, - lastStockReminderSent: settings.lastStockReminderSent ?? null, - lastStockReminderChannel: settings.lastStockReminderChannel ?? null, - lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, - lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, - lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, - lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, - }; + return loadUserSettingsFromDb(userId); } // Get all users with settings for scheduler export async function getAllUserSettings(): Promise { - const allSettings = await db.select().from(userSettings); - return allSettings.map((settings) => ({ - userId: settings.userId, - emailEnabled: settings.emailEnabled, - notificationEmail: settings.notificationEmail, - emailStockReminders: settings.emailStockReminders, - emailIntakeReminders: settings.emailIntakeReminders, - emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, - shoutrrrEnabled: settings.shoutrrrEnabled, - shoutrrrUrl: settings.shoutrrrUrl, - shoutrrrStockReminders: settings.shoutrrrStockReminders, - shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, - shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, - reminderDaysBefore: settings.reminderDaysBefore, - repeatDailyReminders: settings.repeatDailyReminders, - skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, - repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, - reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, - maxNaggingReminders: settings.maxNaggingReminders ?? 5, - lowStockDays: settings.lowStockDays, - normalStockDays: settings.normalStockDays, - highStockDays: settings.highStockDays, - language: settings.language as Language, - stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", - shareMedicationOverview: settings.shareMedicationOverview ?? false, - upcomingTodayOnly: settings.upcomingTodayOnly ?? false, - shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, - swapDashboardMainSections: settings.swapDashboardMainSections ?? false, - lastAutoEmailSent: settings.lastAutoEmailSent, - lastNotificationType: settings.lastNotificationType, - lastNotificationChannel: settings.lastNotificationChannel, - lastReminderMedName: settings.lastReminderMedName ?? null, - lastReminderTakenBy: settings.lastReminderTakenBy ?? null, - lastStockReminderSent: settings.lastStockReminderSent ?? null, - lastStockReminderChannel: settings.lastStockReminderChannel ?? null, - lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, - lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, - lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, - lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, - })); + return getAllUserSettingsFromDb(); } export async function settingsRoutes(app: FastifyInstance) { @@ -792,97 +585,6 @@ export async function settingsRoutes(app: FastifyInstance) { ); } -// Validate and sanitize URL to prevent SSRF attacks -// Returns a reconstructed URL from validated components to break taint tracking -function sanitizeNotificationUrl( - urlStr: string -): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } { - try { - // Support Shoutrrr Discord format: discord://TOKEN@WEBHOOK_ID - if (urlStr.startsWith("discord://")) { - const parsedDiscord = new URL(urlStr); - const webhookId = parsedDiscord.hostname; - const webhookToken = parsedDiscord.username; - - if (!webhookId || !webhookToken) { - return { error: "Invalid Discord URL format" }; - } - - if (!/^\d+$/.test(webhookId)) { - return { error: "Invalid Discord webhook ID" }; - } - - if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) { - return { error: "Invalid Discord webhook token" }; - } - - const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`; - return { url: discordWebhookUrl, isNtfy: false }; - } - - // Convert ntfy:// to https:// for parsing, track if it was ntfy - const isNtfy = urlStr.startsWith("ntfy://"); - const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr; - - const parsed = new URL(normalizedUrl); - - // Only allow http and https protocols - if (!["http:", "https:"].includes(parsed.protocol)) { - return { error: "Only HTTP/HTTPS protocols are allowed" }; - } - - const hostValidationError = validateNotificationHostname(parsed.hostname); - if (hostValidationError) { - return { error: hostValidationError }; - } - - // Reconstruct URL from validated components - this breaks taint tracking - // because we're building a new string from validated parts, not passing through user input - const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`; - - // Extract auth credentials separately for ntfy (they're in the URL but not in host) - const auth = - isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined; - - return { url: reconstructedUrl, isNtfy, auth }; - } catch { - return { error: "Invalid URL format" }; - } -} - -function validateNotificationHostname(hostnameRaw: string): string | null { - const hostname = hostnameRaw.toLowerCase(); - - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { - return "Localhost URLs are not allowed"; - } - - const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); - if (ipMatch) { - const [, a, b] = ipMatch.map(Number); - if ( - a === 10 || - a === 127 || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 169 && b === 254) - ) { - return "Private IP addresses are not allowed"; - } - } - - if ( - hostname.endsWith(".local") || - hostname.endsWith(".internal") || - hostname.endsWith(".lan") || - hostname === "metadata.google.internal" - ) { - return "Internal hostnames are not allowed"; - } - - return null; -} - // Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.) export async function sendShoutrrrNotification( urlStr: string, diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index e2bbabf..df64032 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -1,9 +1,8 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { and, eq, gte, lte } from "drizzle-orm"; -import nodemailer from "nodemailer"; import { db } from "../db/client.js"; -import { getDataDir } from "../db/db-utils.js"; +import { getDataDir } from "../db/path-utils.js"; import { doseTracking, medications, users } from "../db/schema.js"; import { getDateLocale, @@ -13,7 +12,7 @@ import { type Language, t, } from "../i18n/translations.js"; -import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; +import { getAllUserSettings, type UserSettings } from "../routes/settings.js"; import type { ServiceLogger } from "../utils/logger.js"; // Import shared utilities import { @@ -30,20 +29,22 @@ import { type UpcomingIntake, } from "../utils/scheduler-utils.js"; import { computeMedicationCurrentStock } from "./current-stock.js"; -import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js"; +import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js"; +import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js"; const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10); const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json"); -function loadIntakeReminderState(): IntakeReminderState { +function loadIntakeReminderState(logger: ServiceLogger): IntakeReminderState { try { if (existsSync(intakeReminderStateFile)) { return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8")); } - } catch { - // ignore + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`[IntakeReminder] Failed to load reminder state file=${intakeReminderStateFile}: ${errorMessage}`); } return createDefaultIntakeReminderState(); } @@ -52,36 +53,6 @@ function saveIntakeReminderState(state: IntakeReminderState): void { writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2)); } -type MailDeliveryInfo = { - accepted?: unknown; - rejected?: unknown; - response?: unknown; -}; - -function normalizeRecipients(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => (typeof entry === "string" ? entry : String(entry ?? ""))) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function getDeliveryError(info: MailDeliveryInfo): string | null { - const accepted = normalizeRecipients(info.accepted); - const rejected = normalizeRecipients(info.rejected); - - if (accepted.length > 0) return null; - if (rejected.length > 0) { - return `SMTP rejected all recipients: ${rejected.join(", ")}`; - } - - if (typeof info.response === "string" && info.response.trim()) { - return `SMTP did not confirm accepted recipients. Response: ${info.response}`; - } - - return "SMTP did not confirm accepted recipients."; -} - function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string { const intakeDate = intake.intakeTime; const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime(); @@ -269,14 +240,9 @@ async function sendIntakeReminderEmail( currentCount?: number, maxCount?: number ): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + const smtp = getSmtpConfig(); - if (!smtpHost || !smtpUser) { + if (!smtp.host || !smtp.user) { return { success: false, error: "SMTP not configured" }; } @@ -401,39 +367,23 @@ ${getFooterPlain(language)}`; ? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}` : t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") }); - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); + const mailResult = await sendEmailNotification({ + to: email, + subject: `💊 ${subject}`, + text: plainText, + html, + from: smtp.from, + }); - const mailResult = await transporter.sendMail({ - from: smtpFrom, - to: email, - subject: `💊 ${subject}`, - text: plainText, - html, - }); - - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - return { success: false, error: deliveryError }; - } - - return { - success: true, - messageId: mailResult.messageId, - smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return { success: false, error: errorMessage }; + if (!mailResult.success) { + return { success: false, error: mailResult.error ?? "Unknown error" }; } + + return { + success: true, + messageId: mailResult.messageId, + smtpResponse: mailResult.smtpResponse, + }; } async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise { @@ -523,7 +473,7 @@ export async function checkAndSendIntakeRemindersForUser( return; // No medications have reminders enabled for this user } - const state = loadIntakeReminderState(); + const state = loadIntakeReminderState(logger); const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = []; let scheduledIntakesTodayCount = 0; // Get start and end of today in user's timezone (for filtering today's doses only) @@ -842,7 +792,7 @@ export async function checkAndSendIntakeRemindersForUser( repeatNote + `\n\n---\n${getFooterPlain(language)}`; - const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); + const result = await sendPushNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (!result.success) { logger.error( diff --git a/backend/src/services/medication-enrichment.ts b/backend/src/services/medication-enrichment.ts index edfb1f7..234971a 100644 --- a/backend/src/services/medication-enrichment.ts +++ b/backend/src/services/medication-enrichment.ts @@ -1125,10 +1125,20 @@ export function startMedicationEnrichmentService(logger: MedicationEnrichmentLog if (schedulerStarted) return; schedulerStarted = true; - void refreshEmaCatalog("startup").catch(() => undefined); + void refreshEmaCatalog("startup").catch((error: unknown) => { + activeLogger.error( + `[MedicationEnrichment] startup refresh failed: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + }); refreshTimer = setInterval(() => { - void refreshEmaCatalog("scheduled").catch(() => undefined); + void refreshEmaCatalog("scheduled").catch((error: unknown) => { + activeLogger.error( + `[MedicationEnrichment] scheduled refresh failed: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + }); }, EMA_REFRESH_INTERVAL_MS); if (typeof refreshTimer.unref === "function") { diff --git a/backend/src/services/medication-enrichment/adapters.ts b/backend/src/services/medication-enrichment/adapters.ts new file mode 100644 index 0000000..678b016 --- /dev/null +++ b/backend/src/services/medication-enrichment/adapters.ts @@ -0,0 +1,13 @@ +export { + MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT, + MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT, + type MedicationEnrichmentCombinedSource, + type MedicationEnrichmentEnrichRequest, + type MedicationEnrichmentEnrichResponse, + type MedicationEnrichmentPackageOption, + type MedicationEnrichmentSearchResponse, + type MedicationEnrichmentSearchResult, + type MedicationEnrichmentSearchSource, + MedicationEnrichmentServiceError, + type MedicationEnrichmentStrengthOption, +} from "../medication-enrichment.js"; diff --git a/backend/src/services/medication-enrichment/index.ts b/backend/src/services/medication-enrichment/index.ts new file mode 100644 index 0000000..904e093 --- /dev/null +++ b/backend/src/services/medication-enrichment/index.ts @@ -0,0 +1,20 @@ +export { + MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT, + MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT, + type MedicationEnrichmentCombinedSource, + type MedicationEnrichmentEnrichRequest, + type MedicationEnrichmentEnrichResponse, + type MedicationEnrichmentPackageOption, + type MedicationEnrichmentSearchResponse, + type MedicationEnrichmentSearchResult, + type MedicationEnrichmentSearchSource, + MedicationEnrichmentServiceError, + type MedicationEnrichmentStrengthOption, +} from "./adapters.js"; + +export { + enrichMedicationSelection, + searchMedicationEnrichment, + startMedicationEnrichmentCatalogRefresh, + startMedicationEnrichmentService, +} from "./search.js"; diff --git a/backend/src/services/medication-enrichment/search.ts b/backend/src/services/medication-enrichment/search.ts new file mode 100644 index 0000000..cc83647 --- /dev/null +++ b/backend/src/services/medication-enrichment/search.ts @@ -0,0 +1,6 @@ +export { + enrichMedicationSelection, + searchMedicationEnrichment, + startMedicationEnrichmentCatalogRefresh, + startMedicationEnrichmentService, +} from "../medication-enrichment.js"; diff --git a/backend/src/services/medications-service.ts b/backend/src/services/medications-service.ts new file mode 100644 index 0000000..2d94335 --- /dev/null +++ b/backend/src/services/medications-service.ts @@ -0,0 +1,76 @@ +import { forEachScheduledOccurrenceInRange, type Intake, parseIntakesJson } from "../utils/scheduler-utils.js"; + +function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" { + return value === "ml" || value === "tsp" || value === "tbsp"; +} + +export function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> { + if (!intakesJson) return []; + try { + const parsed = JSON.parse(intakesJson); + if (!Array.isArray(parsed)) return []; + return parsed.map((item: unknown) => { + if (!item || typeof item !== "object") return null; + const unit = (item as Record).intakeUnit; + return isIntakeUnit(unit) ? unit : null; + }); + } catch { + return []; + } +} + +export function parseIntakesWithUnits( + intakesJson: string | null | undefined, + legacyRow: { usageJson: string; everyJson: string; startJson: string }, + medicationIntakeRemindersEnabled?: boolean +): Intake[] { + const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled); + const rawUnits = parseRawIntakeUnits(intakesJson); + if (rawUnits.length === 0) return intakes; + + return intakes.map((intake, idx) => ({ + ...intake, + intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null, + })); +} + +export function normalizeDateTime(value: unknown): string | null { + if (value == null) { + return null; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + if (typeof value === "number") { + const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value; + const date = new Date(timestampMs); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + if (typeof value === "string") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + return null; +} + +export function calculateUsageInRange( + blisters: Array>, + start: Date, + end: Date +): number { + if (end.getTime() <= start.getTime()) { + return 0; + } + + let total = 0; + blisters.forEach((blister) => { + forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => { + total += blister.usage; + }); + }); + return Number(total.toFixed(2)); +} diff --git a/backend/src/services/notifications/builders.ts b/backend/src/services/notifications/builders.ts new file mode 100644 index 0000000..f6eebc6 --- /dev/null +++ b/backend/src/services/notifications/builders.ts @@ -0,0 +1,109 @@ +import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js"; + +export type StockReminderItem = { + name: string; + medsLeft: number; + daysLeft: number | null; + depletionDate: string | null; + isCritical?: boolean; +}; + +export type PrescriptionReminderItem = { + name: string; + remainingRefills: number; +}; + +function splitStockItems(items: StockReminderItem[]): { + emptyItems: StockReminderItem[]; + criticalItems: StockReminderItem[]; + lowItems: StockReminderItem[]; +} { + const emptyItems = items.filter((item) => item.medsLeft <= 0); + const criticalItems = items.filter((item) => item.medsLeft > 0 && item.isCritical !== false); + const lowItems = items.filter((item) => item.medsLeft > 0 && item.isCritical === false); + return { emptyItems, criticalItems, lowItems }; +} + +export function buildStockReminderPushNotification( + items: StockReminderItem[], + language: Language +): { title: string; message: string } { + const tr = getTranslations(language); + const { emptyItems, criticalItems, lowItems } = splitStockItems(items); + + const titleParts: string[] = []; + if (emptyItems.length > 0) titleParts.push(`🚨 ${emptyItems.length} ${tr.push.empty}`); + if (criticalItems.length > 0) titleParts.push(`🚨 ${criticalItems.length} ${tr.push.critical}`); + if (lowItems.length > 0) titleParts.push(`⚠️ ${lowItems.length} ${tr.push.lowStock}`); + const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; + + const messageParts: string[] = []; + if (emptyItems.length > 0) { + messageParts.push(`🚨 ${tr.push.emptySection}:`); + emptyItems.forEach((item) => messageParts.push(` • ${item.name}`)); + } + if (criticalItems.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.push.criticalSection}:`); + criticalItems.forEach((item) => + messageParts.push( + ` • ${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}` + ) + ); + } + if (lowItems.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); + lowItems.forEach((item) => + messageParts.push( + ` • ${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}` + ) + ); + } + + return { + title, + message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`, + }; +} + +export function buildPrescriptionReminderPushNotification( + items: PrescriptionReminderItem[], + language: Language +): { title: string; message: string } { + const tr = getTranslations(language); + const emptyItems = items.filter((item) => item.remainingRefills <= 0); + const lowItems = items.filter((item) => item.remainingRefills > 0); + + const titleParts: string[] = []; + if (emptyItems.length > 0) { + titleParts.push( + `🚨 ${emptyItems.length} ${emptyItems.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` + ); + } + if (lowItems.length > 0) { + titleParts.push( + `🚨 ${lowItems.length} ${lowItems.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` + ); + } + + const messageParts: string[] = []; + if (emptyItems.length > 0) { + messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); + emptyItems.forEach((item) => messageParts.push(` • ${item.name}`)); + } + if (lowItems.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); + lowItems.forEach((item) => + messageParts.push( + ` • ${item.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: item.remainingRefills })}` + ) + ); + } + + return { + title: `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`, + message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`, + }; +} diff --git a/backend/src/services/notifications/delivery.ts b/backend/src/services/notifications/delivery.ts new file mode 100644 index 0000000..d0c31db --- /dev/null +++ b/backend/src/services/notifications/delivery.ts @@ -0,0 +1,123 @@ +import nodemailer from "nodemailer"; +import { sendShoutrrrNotification } from "../../routes/settings.js"; + +type MailDeliveryInfo = { + accepted?: unknown; + rejected?: unknown; + response?: unknown; +}; + +function normalizeRecipients(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === "string" ? entry : String(entry ?? ""))) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function getDeliveryError(info: MailDeliveryInfo): string | null { + const accepted = normalizeRecipients(info.accepted); + const rejected = normalizeRecipients(info.rejected); + + if (accepted.length > 0) return null; + if (rejected.length > 0) { + return `SMTP rejected all recipients: ${rejected.join(", ")}`; + } + + if (typeof info.response === "string" && info.response.trim()) { + return `SMTP did not confirm accepted recipients. Response: ${info.response}`; + } + + return "SMTP did not confirm accepted recipients."; +} + +export type EmailDeliveryRequest = { + to: string; + subject: string; + text: string; + html: string; + from?: string; +}; + +export type EmailDeliveryResult = { + success: boolean; + error?: string; + messageId?: string; + smtpResponse?: string; +}; + +export function getSmtpConfig(): { + host?: string; + user?: string; + pass?: string; + port: number; + secure: boolean; + from?: string; +} { + const host = process.env.SMTP_HOST; + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; + const port = parseInt(process.env.SMTP_PORT ?? "587", 10); + const secure = process.env.SMTP_SECURE === "true"; + const from = process.env.SMTP_FROM ?? user; + + return { host, user, pass, port, secure, from }; +} + +export async function sendEmailNotification(input: EmailDeliveryRequest): Promise { + const smtp = getSmtpConfig(); + if (!smtp.host || !smtp.user) { + return { success: false, error: "SMTP not configured" }; + } + + try { + const transporter = nodemailer.createTransport({ + host: smtp.host, + port: smtp.port, + secure: smtp.secure, + auth: { + user: smtp.user, + pass: smtp.pass ?? "", + }, + }); + + const mailResult = await transporter.sendMail({ + from: input.from ?? smtp.from, + to: input.to, + subject: input.subject, + text: input.text, + html: input.html, + }); + + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + return { success: false, error: deliveryError }; + } + + return { + success: true, + messageId: mailResult.messageId, + smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return { success: false, error: errorMessage }; + } +} + +export async function sendPushNotification( + url: string, + title: string, + message: string +): Promise<{ success: boolean; error?: string }> { + try { + const result = await sendShoutrrrNotification(url, title, message); + if (!result.success) { + return { success: false, error: result.error }; + } + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return { success: false, error: errorMessage }; + } +} diff --git a/backend/src/services/notifications/index.ts b/backend/src/services/notifications/index.ts new file mode 100644 index 0000000..11fb885 --- /dev/null +++ b/backend/src/services/notifications/index.ts @@ -0,0 +1,20 @@ +export { + buildPrescriptionReminderPushNotification, + buildStockReminderPushNotification, + type PrescriptionReminderItem, + type StockReminderItem, +} from "./builders.js"; +export { + type EmailDeliveryRequest, + type EmailDeliveryResult, + getSmtpConfig, + sendEmailNotification, + sendPushNotification, +} from "./delivery.js"; +export { + getReminderState, + loadReminderState, + saveReminderState, + updateReminderSentTime, + updateUserReminderSentTime, +} from "./state.js"; diff --git a/backend/src/services/notifications/state.ts b/backend/src/services/notifications/state.ts new file mode 100644 index 0000000..27cc989 --- /dev/null +++ b/backend/src/services/notifications/state.ts @@ -0,0 +1,93 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/client.js"; +import { getDataDir } from "../../db/db-utils.js"; +import { userSettings } from "../../db/schema.js"; +import { + createDefaultReminderState, + getTodayInTimezone, + parseReminderState, + type ReminderState, +} from "../../utils/scheduler-utils.js"; + +const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); + +export function loadReminderState(): ReminderState { + try { + if (existsSync(reminderStateFile)) { + return parseReminderState(readFileSync(reminderStateFile, "utf-8")); + } + } catch { + // ignore + } + return createDefaultReminderState(); +} + +export function saveReminderState(state: ReminderState): void { + writeFileSync(reminderStateFile, JSON.stringify(state, null, 2)); +} + +export function getReminderState(): ReminderState { + return loadReminderState(); +} + +export function updateReminderSentTime( + type: "stock" | "intake" | "prescription" = "stock", + channel: "email" | "push" | "both" = "email" +): void { + const state = loadReminderState(); + const today = getTodayInTimezone(); + saveReminderState({ + ...state, + lastAutoEmailSent: new Date().toISOString(), + lastAutoEmailDate: today, + lastNotificationType: type, + lastNotificationChannel: channel, + }); +} + +// Stock and intake reminders are tracked separately so neither overwrites the other. +export async function updateUserReminderSentTime( + userId: number, + type: "stock" | "intake" | "prescription" = "stock", + channel: "email" | "push" | "both" = "email", + medName?: string, + takenBy?: string +): Promise { + const now = new Date().toISOString(); + if (type === "stock") { + await db + .update(userSettings) + .set({ + lastStockReminderSent: now, + lastStockReminderChannel: channel, + lastStockReminderMedNames: medName ?? null, + }) + .where(eq(userSettings.userId, userId)); + return; + } + + if (type === "prescription") { + await db + .update(userSettings) + .set({ + lastPrescriptionReminderSent: now, + lastPrescriptionReminderChannel: channel, + lastPrescriptionReminderMedNames: medName ?? null, + }) + .where(eq(userSettings.userId, userId)); + return; + } + + await db + .update(userSettings) + .set({ + lastAutoEmailSent: now, + lastNotificationType: type, + lastNotificationChannel: channel, + lastReminderMedName: medName ?? null, + lastReminderTakenBy: takenBy ?? null, + }) + .where(eq(userSettings.userId, userId)); +} diff --git a/backend/src/services/planner-service.ts b/backend/src/services/planner-service.ts new file mode 100644 index 0000000..43450a6 --- /dev/null +++ b/backend/src/services/planner-service.ts @@ -0,0 +1,57 @@ +import { getPlannerUnitKind, isAmountBasedPackageType } from "../utils/package-profiles.js"; + +// Escape HTML to prevent XSS in email templates. +export function escapeHtml(text: string): string { + const htmlEscapes: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char); +} + +type MailDeliveryInfo = { + accepted?: unknown; + rejected?: unknown; + response?: unknown; +}; + +function normalizeRecipients(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === "string" ? entry : String(entry ?? ""))) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function getDeliveryError(info: MailDeliveryInfo): string | null { + const accepted = normalizeRecipients(info.accepted); + const rejected = normalizeRecipients(info.rejected); + + if (accepted.length > 0) return null; + if (rejected.length > 0) { + return `SMTP rejected all recipients: ${rejected.join(", ")}`; + } + + if (typeof info.response === "string" && info.response.trim()) { + return `SMTP did not confirm accepted recipients. Response: ${info.response}`; + } + + return "SMTP did not confirm accepted recipients."; +} + +export function isContainerPackage(packageType?: string): boolean { + return isAmountBasedPackageType(packageType); +} + +export function getPlannerUnit( + packageType: string | undefined, + tr: { common: { units: string; ml: string; pills: string } } +): string { + const unitKind = getPlannerUnitKind(packageType); + if (unitKind === "units") return tr.common.units; + if (unitKind === "ml") return tr.common.ml; + return tr.common.pills; +} diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index d3d7fc8..c240331 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -1,12 +1,11 @@ -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, mkdirSync, openSync, statSync, unlinkSync } from "node:fs"; import { resolve } from "node:path"; import { and, eq } from "drizzle-orm"; -import nodemailer from "nodemailer"; import { db } from "../db/client.js"; -import { getDataDir } from "../db/db-utils.js"; -import { doseTracking, medications, userSettings } from "../db/schema.js"; +import { getDataDir } from "../db/path-utils.js"; +import { doseTracking, medications } from "../db/schema.js"; import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js"; -import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; +import { getAllUserSettings, type UserSettings } from "../routes/settings.js"; import type { ServiceLogger } from "../utils/logger.js"; import { isAmountBasedPackageType, @@ -19,7 +18,6 @@ import { type Blister, calculateDepletionInfo, countScheduledOccurrencesInRange, - createDefaultReminderState, formatInTimezone, getCurrentHourInTimezone, getDateOnlyTimestamp, @@ -31,10 +29,16 @@ import { normalizeIntakeUsageForStock, parseIntakesJson, parseLocalDateTime, - parseReminderState, parseTakenByJson, - type ReminderState, } from "../utils/scheduler-utils.js"; +import { + buildPrescriptionReminderPushNotification, + buildStockReminderPushNotification, +} from "./notifications/builders.js"; +import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js"; +import { loadReminderState, saveReminderState, updateUserReminderSentTime } from "./notifications/state.js"; + +export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js"; function escapeHtml(text: string): string { const htmlEscapes: Record = { @@ -47,39 +51,8 @@ function escapeHtml(text: string): string { return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char); } -type MailDeliveryInfo = { - accepted?: unknown; - rejected?: unknown; - response?: unknown; -}; - -function normalizeRecipients(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => (typeof entry === "string" ? entry : String(entry ?? ""))) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function getDeliveryError(info: MailDeliveryInfo): string | null { - const accepted = normalizeRecipients(info.accepted); - const rejected = normalizeRecipients(info.rejected); - - if (accepted.length > 0) return null; - if (rejected.length > 0) { - return `SMTP rejected all recipients: ${rejected.join(", ")}`; - } - - if (typeof info.response === "string" && info.response.trim()) { - return `SMTP did not confirm accepted recipients. Response: ${info.response}`; - } - - return "SMTP did not confirm accepted recipients."; -} - const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time -const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); const reminderLocksDir = resolve(getDataDir(), "scheduler-locks"); const LOCK_STALE_MS = 15 * 60 * 1000; @@ -131,86 +104,6 @@ function releaseReminderSendLock(lockFilePath: string | null): void { } } -function loadReminderState(): ReminderState { - try { - if (existsSync(reminderStateFile)) { - return parseReminderState(readFileSync(reminderStateFile, "utf-8")); - } - } catch { - // ignore - } - return createDefaultReminderState(); -} - -function saveReminderState(state: ReminderState): void { - writeFileSync(reminderStateFile, JSON.stringify(state, null, 2)); -} - -export function getReminderState(): ReminderState { - return loadReminderState(); -} - -export function updateReminderSentTime( - type: "stock" | "intake" | "prescription" = "stock", - channel: "email" | "push" | "both" = "email" -): void { - const state = loadReminderState(); - const today = getTodayInTimezone(); - saveReminderState({ - ...state, - lastAutoEmailSent: new Date().toISOString(), - lastAutoEmailDate: today, - lastNotificationType: type, - lastNotificationChannel: channel, - }); -} - -// Update user settings in database when reminder is sent -// Stock and intake reminders are tracked separately so neither overwrites the other -export async function updateUserReminderSentTime( - userId: number, - type: "stock" | "intake" | "prescription" = "stock", - channel: "email" | "push" | "both" = "email", - medName?: string, - takenBy?: string -): Promise { - const now = new Date().toISOString(); - if (type === "stock") { - // Write to dedicated stock reminder columns only — do NOT touch the shared - // lastNotificationType column, as that would block intake reminder display - await db - .update(userSettings) - .set({ - lastStockReminderSent: now, - lastStockReminderChannel: channel, - lastStockReminderMedNames: medName ?? null, - }) - .where(eq(userSettings.userId, userId)); - } else if (type === "prescription") { - // Write to dedicated prescription reminder columns only - await db - .update(userSettings) - .set({ - lastPrescriptionReminderSent: now, - lastPrescriptionReminderChannel: channel, - lastPrescriptionReminderMedNames: medName ?? null, - }) - .where(eq(userSettings.userId, userId)); - } else { - // Write to intake reminder columns - await db - .update(userSettings) - .set({ - lastAutoEmailSent: now, - lastNotificationType: type, - lastNotificationChannel: channel, - lastReminderMedName: medName ?? null, - lastReminderTakenBy: takenBy ?? null, - }) - .where(eq(userSettings.userId, userId)); - } -} - type LowStockItem = { name: string; medsLeft: number; @@ -461,14 +354,8 @@ async function sendReminderEmail( language: Language, isRepeatDaily: boolean = false ): Promise<{ success: boolean; error?: string }> { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - - if (!smtpHost || !smtpUser) { + const smtp = getSmtpConfig(); + if (!smtp.host || !smtp.user) { return { success: false, error: "SMTP not configured" }; } @@ -590,35 +477,19 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix; const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural }); - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); + const emailResult = await sendEmailNotification({ + to: email, + subject, + text: plainText, + html, + from: smtp.from, + }); - const mailResult = await transporter.sendMail({ - from: smtpFrom, - to: email, - subject, - text: plainText, - html, - }); - - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); - } - - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return { success: false, error: errorMessage }; + if (!emailResult.success) { + return { success: false, error: emailResult.error ?? "Unknown error" }; } + + return { success: true }; } async function checkAndSendReminder(logger: ServiceLogger): Promise { @@ -703,41 +574,8 @@ async function checkAndSendReminderForUser( } if (stockPushEnabled) { - const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0); - const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical); - const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical); - - const titleParts: string[] = []; - if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); - if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`); - if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); - const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; - - const messageParts: string[] = []; - if (emptyMeds.length > 0) { - messageParts.push(`🚨 ${tr.push.emptySection}:`); - emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`)); - } - if (criticalMeds.length > 0) { - if (messageParts.length > 0) messageParts.push(""); - messageParts.push(`🚨 ${tr.push.criticalSection}:`); - criticalMeds.forEach((m) => - messageParts.push( - ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` - ) - ); - } - if (lowStockMeds.length > 0) { - if (messageParts.length > 0) messageParts.push(""); - messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); - lowStockMeds.forEach((m) => - messageParts.push( - ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` - ) - ); - } - const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; - const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); + const pushPayload = buildStockReminderPushNotification(allLowStock, language); + const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message); shoutrrrSuccess = result.success; if (!result.success) { logger.error(`[Reminder] Failed to send stock push: ${result.error}`); @@ -824,22 +662,9 @@ async function checkAndSendReminderForUser( let shoutrrrSuccess = false; if (prescriptionEmailEnabled) { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - - if (smtpHost && smtpUser) { + const smtp = getSmtpConfig(); + if (smtp.host && smtp.user) { try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { user: smtpUser, pass: smtpPass ?? "" }, - }); - const subject = allPrescriptionLow.length === 1 ? tr.prescriptionReminder.subjectSingle @@ -919,16 +744,15 @@ async function checkAndSendReminderForUser( `; const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`; - const mailResult = await transporter.sendMail({ - from: smtpFrom, + const mailResult = await sendEmailNotification({ to: settings.notificationEmail!, subject, text, html, + from: smtp.from, }); - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); + if (!mailResult.success) { + throw new Error(mailResult.error ?? "Unknown error"); } emailSuccess = true; } catch (error) { @@ -939,35 +763,8 @@ async function checkAndSendReminderForUser( } if (prescriptionPushEnabled) { - const titleParts: string[] = []; - if (emptyRx.length > 0) - titleParts.push( - `🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` - ); - if (lowRx.length > 0) - titleParts.push( - `🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` - ); - const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`; - - const messageParts: string[] = []; - if (emptyRx.length > 0) { - messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); - for (const m of emptyRx) { - messageParts.push(` • ${m.name}`); - } - } - if (lowRx.length > 0) { - if (emptyRx.length > 0) messageParts.push(""); - messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); - for (const m of lowRx) { - messageParts.push( - ` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}` - ); - } - } - const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; - const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); + const pushPayload = buildPrescriptionReminderPushNotification(allPrescriptionLow, language); + const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message); shoutrrrSuccess = result.success; if (!result.success) { logger.error(`[Reminder] Failed to send prescription push: ${result.error}`); diff --git a/backend/src/services/settings-service.ts b/backend/src/services/settings-service.ts new file mode 100644 index 0000000..4b01a95 --- /dev/null +++ b/backend/src/services/settings-service.ts @@ -0,0 +1,328 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { userSettings } from "../db/schema.js"; +import type { Language } from "../i18n/translations.js"; + +export type UserSettings = { + userId: number; + emailEnabled: boolean; + notificationEmail: string | null; + emailStockReminders: boolean; + emailIntakeReminders: boolean; + emailPrescriptionReminders: boolean; + shoutrrrEnabled: boolean; + shoutrrrUrl: string | null; + shoutrrrStockReminders: boolean; + shoutrrrIntakeReminders: boolean; + shoutrrrPrescriptionReminders: boolean; + reminderDaysBefore: number; + repeatDailyReminders: boolean; + skipRemindersForTakenDoses: boolean; + repeatRemindersEnabled: boolean; + reminderRepeatIntervalMinutes: number; + maxNaggingReminders: number; + lowStockDays: number; + normalStockDays: number; + highStockDays: number; + language: Language; + stockCalculationMode: "automatic" | "manual"; + shareMedicationOverview: boolean; + upcomingTodayOnly: boolean; + shareScheduleTodayOnly: boolean; + swapDashboardMainSections: boolean; + lastAutoEmailSent: string | null; + lastNotificationType: string | null; + lastNotificationChannel: string | null; + lastReminderMedName: string | null; + lastReminderTakenBy: string | null; + lastStockReminderSent: string | null; + lastStockReminderChannel: string | null; + lastStockReminderMedNames: string | null; + lastPrescriptionReminderSent: string | null; + lastPrescriptionReminderChannel: string | null; + lastPrescriptionReminderMedNames: string | null; +}; + +export function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const normalizedMessage = errorMessage.toLowerCase(); + + if ( + normalizedMessage.includes("smtp rejected all recipients") || + normalizedMessage.includes("all recipients were rejected") || + normalizedMessage.includes("recipient address rejected") || + normalizedMessage.includes("nullmx") + ) { + return { + status: 400, + code: "EMAIL_RECIPIENT_REJECTED", + message: `Failed to send email: ${errorMessage}`, + }; + } + + if (errorMessage.includes("SMTP did not confirm accepted recipients")) { + return { + status: 502, + code: "SMTP_DELIVERY_UNCONFIRMED", + message: `Failed to send email: ${errorMessage}`, + }; + } + + return { + status: 500, + code: "TEST_EMAIL_FAILED", + message: `Failed to send email: ${errorMessage}`, + }; +} + +export function getNotificationProvider(url: string): string { + if (url.startsWith("discord://")) return "discord"; + if (url.startsWith("telegram://")) return "telegram"; + if (url.startsWith("gotify://")) return "gotify"; + if (url.startsWith("pushover://")) return "pushover"; + if (url.startsWith("ntfy://")) return "ntfy"; + + try { + const parsed = new URL(url); + return parsed.hostname || "https"; + } catch { + return "unknown"; + } +} + +function envBool(key: string, defaultVal: boolean): boolean { + const val = process.env[key]; + if (val === undefined) return defaultVal; + return val === "true" || val === "1"; +} + +function envInt(key: string, defaultVal: number): number { + const val = process.env[key]; + if (val === undefined) return defaultVal; + const parsed = parseInt(val, 10); + return Number.isNaN(parsed) ? defaultVal : parsed; +} + +export function getDefaultSettings() { + return { + emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false), + notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null, + emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true), + emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true), + emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true), + shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false), + shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null, + shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true), + shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true), + shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true), + reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7), + repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false), + skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false), + repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false), + reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30), + maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5), + lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30), + normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90), + highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180), + language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en", + stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic", + shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false), + upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false), + shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false), + swapDashboardMainSections: false, + lastAutoEmailSent: null, + lastNotificationType: null, + lastNotificationChannel: null, + lastReminderMedName: null, + lastReminderTakenBy: null, + lastStockReminderSent: null, + lastStockReminderChannel: null, + lastStockReminderMedNames: null, + lastPrescriptionReminderSent: null, + lastPrescriptionReminderChannel: null, + lastPrescriptionReminderMedNames: null, + }; +} + +export function validateNotificationHostname(hostnameRaw: string): string | null { + const hostname = hostnameRaw.toLowerCase(); + + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { + return "Localhost URLs are not allowed"; + } + + const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (ipMatch) { + const [, a, b] = ipMatch.map(Number); + if ( + a === 10 || + a === 127 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ) { + return "Private IP addresses are not allowed"; + } + } + + if ( + hostname.endsWith(".local") || + hostname.endsWith(".internal") || + hostname.endsWith(".lan") || + hostname === "metadata.google.internal" + ) { + return "Internal hostnames are not allowed"; + } + + return null; +} + +export function sanitizeNotificationUrl( + urlStr: string +): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } { + try { + if (urlStr.startsWith("discord://")) { + const parsedDiscord = new URL(urlStr); + const webhookId = parsedDiscord.hostname; + const webhookToken = parsedDiscord.username; + + if (!webhookId || !webhookToken) { + return { error: "Invalid Discord URL format" }; + } + + if (!/^\d+$/.test(webhookId)) { + return { error: "Invalid Discord webhook ID" }; + } + + if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) { + return { error: "Invalid Discord webhook token" }; + } + + const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`; + return { url: discordWebhookUrl, isNtfy: false }; + } + + const isNtfy = urlStr.startsWith("ntfy://"); + const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr; + const parsed = new URL(normalizedUrl); + + if (!["http:", "https:"].includes(parsed.protocol)) { + return { error: "Only HTTP/HTTPS protocols are allowed" }; + } + + const hostValidationError = validateNotificationHostname(parsed.hostname); + if (hostValidationError) { + return { error: hostValidationError }; + } + + const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`; + const auth = + isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined; + + return { url: reconstructedUrl, isNtfy, auth }; + } catch { + return { error: "Invalid URL format" }; + } +} + +async function getOrCreateUserSettings(userId: number) { + let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + + if (!settings) { + [settings] = await db + .insert(userSettings) + .values({ + userId, + ...getDefaultSettings(), + }) + .returning(); + } + + return settings; +} + +export async function loadUserSettingsFromDb(userId: number): Promise { + const settings = await getOrCreateUserSettings(userId); + return { + userId: settings.userId, + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail, + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, + shoutrrrEnabled: settings.shoutrrrEnabled, + shoutrrrUrl: settings.shoutrrrUrl, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: settings.maxNaggingReminders ?? 5, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + language: settings.language as Language, + stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", + shareMedicationOverview: settings.shareMedicationOverview ?? false, + upcomingTodayOnly: settings.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: settings.swapDashboardMainSections ?? false, + lastAutoEmailSent: settings.lastAutoEmailSent, + lastNotificationType: settings.lastNotificationType, + lastNotificationChannel: settings.lastNotificationChannel, + lastReminderMedName: settings.lastReminderMedName ?? null, + lastReminderTakenBy: settings.lastReminderTakenBy ?? null, + lastStockReminderSent: settings.lastStockReminderSent ?? null, + lastStockReminderChannel: settings.lastStockReminderChannel ?? null, + lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, + lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, + lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, + lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, + }; +} + +export async function getAllUserSettingsFromDb(): Promise { + const allSettings = await db.select().from(userSettings); + return allSettings.map((settings) => ({ + userId: settings.userId, + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail, + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, + shoutrrrEnabled: settings.shoutrrrEnabled, + shoutrrrUrl: settings.shoutrrrUrl, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: settings.maxNaggingReminders ?? 5, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + language: settings.language as Language, + stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", + shareMedicationOverview: settings.shareMedicationOverview ?? false, + upcomingTodayOnly: settings.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: settings.swapDashboardMainSections ?? false, + lastAutoEmailSent: settings.lastAutoEmailSent, + lastNotificationType: settings.lastNotificationType, + lastNotificationChannel: settings.lastNotificationChannel, + lastReminderMedName: settings.lastReminderMedName ?? null, + lastReminderTakenBy: settings.lastReminderTakenBy ?? null, + lastStockReminderSent: settings.lastStockReminderSent ?? null, + lastStockReminderChannel: settings.lastStockReminderChannel ?? null, + lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, + lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, + lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, + lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, + })); +} diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts index 5050b55..4d7a017 100644 --- a/backend/src/test/database.test.ts +++ b/backend/src/test/database.test.ts @@ -248,10 +248,10 @@ describe("Database Client Utilities", () => { expect(result.success).toBe(true); }); - it("should create .write-test file", () => { + it("should not leave .write-test residue", () => { const result = ensureDataDirectory(testDir); expect(result.success).toBe(true); - expect(existsSync(resolve(testDir, ".write-test"))).toBe(true); + expect(existsSync(resolve(testDir, ".write-test"))).toBe(false); }); it("should return error for invalid path", () => { diff --git a/backend/src/test/db-client.test.ts b/backend/src/test/db-client.test.ts index c458f5c..54491b8 100644 --- a/backend/src/test/db-client.test.ts +++ b/backend/src/test/db-client.test.ts @@ -41,16 +41,22 @@ async function loadDbClientModule(options: ClientTestOptions = {}) { const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] }); const ensureDefaultUser = vi.fn().mockResolvedValue(false); - vi.doMock("../db/db-utils.js", () => ({ - buildDbUrl: vi.fn(), + vi.doMock("../db/path-utils.js", () => ({ getDataDir: vi.fn(), + buildDbUrl: vi.fn(), ensureDataDirectory, getDbPaths, + })); + + vi.doMock("../db/migration-utils.js", () => ({ runDrizzleMigrations, runAlterMigrations, + ensureDefaultUser, + })); + + vi.doMock("../db/repair-utils.js", () => ({ repairTrailingHyphenDoseIds, repairOrphanedDoseIds, - ensureDefaultUser, })); const log = { diff --git a/backend/src/test/decomposition-services.test.ts b/backend/src/test/decomposition-services.test.ts new file mode 100644 index 0000000..e9e20bc --- /dev/null +++ b/backend/src/test/decomposition-services.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { + calculateUsageInRange, + normalizeDateTime, + parseIntakesWithUnits, + parseRawIntakeUnits, +} from "../services/medications-service.js"; +import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js"; + +describe("medications-service decomposition regression", () => { + it("preserves intake unit parsing from unified intakes_json", () => { + const intakesJson = JSON.stringify([ + { usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", intakeUnit: "ml" }, + { usage: 2, every: 1, start: "2026-01-01T20:00:00.000Z", intakeUnit: "bogus" }, + ]); + + expect(parseRawIntakeUnits(intakesJson)).toEqual(["ml", null]); + + const parsed = parseIntakesWithUnits( + intakesJson, + { + usageJson: "[1,2]", + everyJson: "[1,1]", + startJson: '["2026-01-01T08:00:00.000Z","2026-01-01T20:00:00.000Z"]', + }, + false + ); + + expect(parsed[0]?.intakeUnit).toBe("ml"); + expect(parsed[1]?.intakeUnit).toBeNull(); + }); + + it("normalizes date-time values and keeps invalid input null-safe", () => { + expect(normalizeDateTime("2026-01-01T00:00:00.000Z")).toBe("2026-01-01T00:00:00.000Z"); + expect(normalizeDateTime(1_767_225_600)).toBe("2026-01-01T00:00:00.000Z"); + expect(normalizeDateTime("not-a-date")).toBeNull(); + expect(normalizeDateTime(undefined)).toBeNull(); + }); + + it("calculates range usage with split-safe helper behavior", () => { + const usage = calculateUsageInRange( + [ + { usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", scheduleMode: "interval", weekdays: [] }, + { usage: 0.5, every: 1, start: "2026-01-01T20:00:00.000Z", scheduleMode: "interval", weekdays: [] }, + ], + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-01-02T00:00:00.000Z") + ); + + expect(usage).toBe(1.5); + }); +}); + +describe("planner-service decomposition regression", () => { + it("keeps HTML escaping and SMTP delivery error parsing stable", () => { + expect(escapeHtml(``)).toBe("<script>alert('x')</script>"); + expect(getDeliveryError({ accepted: ["ok@example.com"], rejected: [] })).toBeNull(); + expect(getDeliveryError({ accepted: [], rejected: ["bad@example.com"] })).toContain("SMTP rejected all recipients"); + expect(getDeliveryError({ accepted: [], rejected: [], response: "550 relay denied" })).toContain( + "550 relay denied" + ); + }); + + it("maps package type to expected planner units after service extraction", () => { + const tr = { common: { units: "units", ml: "ml", pills: "pills" } }; + + expect(isContainerPackage("bottle")).toBe(true); + expect(isContainerPackage("blister")).toBe(false); + expect(getPlannerUnit("tube", tr)).toBe("units"); + expect(getPlannerUnit("liquid_container", tr)).toBe("ml"); + expect(getPlannerUnit("bottle", tr)).toBe("pills"); + expect(getPlannerUnit("blister", tr)).toBe("pills"); + }); +}); + +describe("settings-service decomposition regression", () => { + it("keeps notification URL and classification helpers stable", async () => { + vi.resetModules(); + vi.doMock("../db/client.js", () => ({ db: {} })); + vi.doMock("../db/schema.js", () => ({ userSettings: { userId: "userId" } })); + + const { classifyTestEmailFailure, getNotificationProvider, sanitizeNotificationUrl, validateNotificationHostname } = + await import("../services/settings-service.js"); + + expect(classifyTestEmailFailure(new Error("SMTP rejected all recipients: person@example.com"))).toMatchObject({ + status: 400, + code: "EMAIL_RECIPIENT_REJECTED", + }); + expect(classifyTestEmailFailure(new Error("SMTP did not confirm accepted recipients."))).toMatchObject({ + status: 502, + code: "SMTP_DELIVERY_UNCONFIRMED", + }); + expect(getNotificationProvider("telegram://token@chat-id")).toBe("telegram"); + expect(getNotificationProvider("https://hooks.slack.com/services/a/b/c")).toBe("hooks.slack.com"); + + expect(validateNotificationHostname("127.0.0.1")).toContain("not allowed"); + expect(validateNotificationHostname("example.com")).toBeNull(); + + expect(sanitizeNotificationUrl("discord://abc@not-a-number")).toEqual({ error: "Invalid Discord webhook ID" }); + expect(sanitizeNotificationUrl("ntfy://user:pass@ntfy.sh/topic")).toMatchObject({ + url: "https://ntfy.sh/topic", + isNtfy: true, + auth: { user: "user", pass: "pass" }, + }); + }); +}); diff --git a/backend/src/test/medication-enrichment.test.ts b/backend/src/test/medication-enrichment.test.ts index 8dd4953..03d5865 100644 --- a/backend/src/test/medication-enrichment.test.ts +++ b/backend/src/test/medication-enrichment.test.ts @@ -705,4 +705,39 @@ describe("medication enrichment", () => { await app.close(); }); + + it("keeps split module exports aligned with the canonical enrichment service", async () => { + const indexExports = await import("../services/medication-enrichment/index.js"); + const searchExports = await import("../services/medication-enrichment/search.js"); + const adapterExports = await import("../services/medication-enrichment/adapters.js"); + const canonical = await import("../services/medication-enrichment.js"); + + expect(indexExports.searchMedicationEnrichment).toBe(canonical.searchMedicationEnrichment); + expect(indexExports.enrichMedicationSelection).toBe(canonical.enrichMedicationSelection); + expect(searchExports.searchMedicationEnrichment).toBe(canonical.searchMedicationEnrichment); + expect(adapterExports.MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT).toBe( + canonical.MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT + ); + expect(adapterExports.MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT).toBe( + canonical.MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT + ); + }); + + it("returns transport-safe 503 payload when search lookup fails unexpectedly", async () => { + const app = await buildApp(); + fetchMock.mockRejectedValue(new Error("network unavailable")); + + const response = await app.inject({ + method: "GET", + url: "/medication-enrichment/search?q=aspirin&limit=1", + }); + + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ + error: "Medication enrichment is temporarily unavailable.", + code: "MEDICATION_ENRICHMENT_UNAVAILABLE", + }); + + await app.close(); + }); }); diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts index d38e205..c27785f 100644 --- a/backend/src/test/setup.ts +++ b/backend/src/test/setup.ts @@ -13,6 +13,7 @@ import { type Client, createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach } from "vitest"; import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Get migrations folder path @@ -315,5 +316,13 @@ export async function clearTestData(client: Client): Promise { // ============================================================================= // Set test environment +process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env"; process.env.AUTH_ENABLED = "false"; +process.env.OIDC_ENABLED = "false"; process.env.NODE_ENV = "test"; + +afterEach(() => { + process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env"; + process.env.AUTH_ENABLED = "false"; + process.env.OIDC_ENABLED = "false"; +}); diff --git a/backend/src/utils/server-config.ts b/backend/src/utils/server-config.ts index ac0098b..43a979b 100644 --- a/backend/src/utils/server-config.ts +++ b/backend/src/utils/server-config.ts @@ -6,7 +6,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { resolve } from "node:path"; import type { CookieSerializeOptions } from "@fastify/cookie"; -import { getDataDir } from "../db/db-utils.js"; +import { getDataDir } from "../db/path-utils.js"; /** * Parse comma-separated CORS origins string