'use strict'; const os = require('os'); const fs = require('fs'); const path = require('path'); const { getDb, getSetting, setSetting } = require('../db/database'); const { BACKUP_DIR } = require('./backupService'); // ─── Helpers ───────────────────────────────────────────────────────────────── function parseBool(key, defaultTrue = true) { const v = getSetting(key); if (v === null) return defaultTrue; return v !== 'false'; } function parseIntSetting(key, fallback) { const v = parseInt(getSetting(key) || '', 10); return isNaN(v) ? fallback : v; } // ─── Individual cleanup tasks ───────────────────────────────────────────────── /** * Delete rows from import_sessions where expires_at has passed. * These are created by the spreadsheet import preview endpoint (24h TTL). */ function pruneExpiredImportSessions() { const result = getDb() .prepare("DELETE FROM import_sessions WHERE expires_at <= datetime('now')") .run(); return result.changes; } /** * Remove stale export temp files from the OS temp directory. * * SQLite exports (user-db): bill-tracker-user-{id}-{ts}.sqlite * Written by the /export/user-db route, deleted in the download callback. * Server crash can leave orphans → swept with maxAgeHours. * * Excel exports (user-excel): bill-tracker-user-{id}-{ts}.xlsx * Currently streamed in-memory (no disk file), but guard exists so that * if the code path ever changes, xlsx files are always removed within 24 h. */ function pruneStaleExportFiles(maxAgeHours) { const tmpDir = os.tmpdir(); const sqliteCutoff = maxAgeHours * 60 * 60 * 1000; const xlsxCutoff = 24 * 60 * 60 * 1000; // xlsx files must not outlive 24 h const now = Date.now(); let removed = 0; let errors = 0; let entries; try { entries = fs.readdirSync(tmpDir); } catch (err) { console.warn('[cleanup] Cannot read tmpdir:', err.message); return { removed, errors: 1 }; } for (const name of entries) { if (!name.startsWith('bill-tracker-user-')) continue; const isXlsx = name.endsWith('.xlsx'); const isSqlite = name.endsWith('.sqlite'); if (!isXlsx && !isSqlite) continue; const cutoff = isXlsx ? xlsxCutoff : sqliteCutoff; const full = path.join(tmpDir, name); try { const stat = fs.statSync(full); if (now - stat.mtimeMs > cutoff) { fs.unlinkSync(full); removed++; } } catch { errors++; } } return { removed, errors }; } /** * Remove orphaned .partial and .upload backup files from the backup directory. * These are left behind only if the server crashes mid-backup or mid-import. * Uses a 2-hour cutoff so an in-progress operation is never interrupted. */ function pruneOrphanedBackupPartials(backupDir) { const cutoff = 2 * 60 * 60 * 1000; const now = Date.now(); let removed = 0; let errors = 0; if (!backupDir || !fs.existsSync(backupDir)) return { removed, errors }; let entries; try { entries = fs.readdirSync(backupDir); } catch (err) { console.warn('[cleanup] Cannot read backup dir:', err.message); return { removed, errors: 1 }; } for (const name of entries) { if (!name.endsWith('.partial') && !name.endsWith('.upload')) continue; const full = path.join(backupDir, name); try { const stat = fs.statSync(full); if (now - stat.mtimeMs > cutoff) { fs.unlinkSync(full); removed++; } } catch { errors++; } } return { removed, errors }; } /** * Trim import_history rows older than maxAgeDays. * Rows are per-user audit records. This task is disabled by default. */ function pruneImportHistory(maxAgeDays) { if (!maxAgeDays || maxAgeDays <= 0) return 0; const result = getDb() .prepare("DELETE FROM import_history WHERE imported_at < datetime('now', ?)") .run(`-${maxAgeDays} days`); return result.changes; } /** * Permanently purge soft-deleted bills and categories after a 30-day recovery * window. Bill deletion cascades to bill-owned records via foreign keys. */ function pruneSoftDeletedFinancialRecords(maxAgeDays = 30) { const db = getDb(); const cutoff = `-${maxAgeDays} days`; const purge = db.transaction(() => { const bills = db.prepare("DELETE FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes; const categories = db.prepare("DELETE FROM categories WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes; return { bills, categories }; }); return purge(); } // ─── Settings ───────────────────────────────────────────────────────────────── function readSettings() { return { import_sessions_enabled: parseBool('cleanup_import_sessions_enabled', true), temp_exports_enabled: parseBool('cleanup_temp_exports_enabled', true), temp_export_max_age_hours: parseIntSetting('cleanup_temp_export_max_age_hours', 2), backup_partials_enabled: parseBool('cleanup_backup_partials_enabled', true), import_history_enabled: parseBool('cleanup_import_history_enabled', false), import_history_max_age_days: parseIntSetting('cleanup_import_history_max_age_days', 365), }; } function validateAndApplySettings(input = {}) { const toSave = {}; const bool = (key, settingKey) => { if (input[key] !== undefined) toSave[settingKey] = input[key] ? 'true' : 'false'; }; const rangedInt = (key, settingKey, min, max, label) => { if (input[key] === undefined) return; const v = parseInt(input[key], 10); if (isNaN(v) || v < min || v > max) { const err = new Error(`${label} must be between ${min} and ${max}`); err.status = 400; throw err; } toSave[settingKey] = String(v); }; bool('import_sessions_enabled', 'cleanup_import_sessions_enabled'); bool('temp_exports_enabled', 'cleanup_temp_exports_enabled'); bool('backup_partials_enabled', 'cleanup_backup_partials_enabled'); bool('import_history_enabled', 'cleanup_import_history_enabled'); rangedInt('temp_export_max_age_hours', 'cleanup_temp_export_max_age_hours', 1, 72, 'temp_export_max_age_hours'); rangedInt('import_history_max_age_days', 'cleanup_import_history_max_age_days', 30, 3650, 'import_history_max_age_days'); for (const [k, v] of Object.entries(toSave)) setSetting(k, v); return readSettings(); } // ─── Main runner ────────────────────────────────────────────────────────────── async function runAllCleanup() { const settings = readSettings(); const tasks = {}; if (settings.import_sessions_enabled) { const pruned = pruneExpiredImportSessions(); tasks.import_sessions = { pruned }; } if (settings.temp_exports_enabled) { tasks.temp_export_files = pruneStaleExportFiles(settings.temp_export_max_age_hours); } if (settings.backup_partials_enabled) { tasks.backup_partials = pruneOrphanedBackupPartials(BACKUP_DIR); } if (settings.import_history_enabled) { const pruned = pruneImportHistory(settings.import_history_max_age_days); tasks.import_history = { pruned }; } tasks.soft_deleted_records = pruneSoftDeletedFinancialRecords(30); // Prune match suggestion rejections older than 90 days try { const { changes } = getDb().prepare( "DELETE FROM match_suggestion_rejections WHERE created_at <= datetime('now', '-90 days')" ).run(); tasks.suggestion_rejections = { pruned: changes }; } catch { tasks.suggestion_rejections = { pruned: 0 }; } const ran_at = new Date().toISOString(); setSetting('cleanup_last_run_at', ran_at); setSetting('cleanup_last_result', JSON.stringify(tasks)); console.log(`[cleanup] Ran at ${ran_at}:`, JSON.stringify(tasks)); return { ran_at, tasks }; } // ─── Status ─────────────────────────────────────────────────────────────────── function getCleanupStatus() { const raw = getSetting('cleanup_last_result'); let last_result = null; try { if (raw) last_result = JSON.parse(raw); } catch {} return { settings: readSettings(), last_run_at: getSetting('cleanup_last_run_at') || null, last_result, }; } module.exports = { runAllCleanup, getCleanupStatus, readSettings, validateAndApplySettings, // Exported individually so the daily worker can call them by name in logs pruneExpiredImportSessions, pruneStaleExportFiles, pruneOrphanedBackupPartials, pruneImportHistory, pruneSoftDeletedFinancialRecords, };