const express = require('express'); const router = express.Router(); const fs = require('fs'); const os = require('os'); const { getDb, getSetting } = require('../db/database'); const { getStatusRuntime, recordError } = require('../services/statusRuntime'); const { listBackups } = require('../services/backupService'); const { getScheduleStatus } = require('../services/backupScheduler'); const { checkForUpdates } = require('../services/updateCheckService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { getBankSyncConfig } = require('../services/bankSyncConfigService'); const startTime = Date.now(); let pkg; try { pkg = require('../package.json'); } catch { pkg = { name: 'bill-tracker', version: '1.0.0' }; } function errorMessage(err) { return err?.message || String(err); } // SQLite stores datetime('now') as "YYYY-MM-DD HH:MM:SS" (UTC, no Z). // Without the Z suffix, JS Date parses it as LOCAL time, creating timezone // inconsistencies when mixed with ISO strings that do have Z. // This ensures all timestamps sent to clients are unambiguous UTC ISO strings. function toIso(sqliteOrIso) { if (!sqliteOrIso) return null; const s = String(sqliteOrIso).trim(); // Already ISO with Z or offset — return as-is if (s.includes('T') && (s.endsWith('Z') || s.match(/[+-]\d{2}:\d{2}$/))) return s; // SQLite format "YYYY-MM-DD HH:MM:SS" — append Z to declare it UTC if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(s)) return s.replace(' ', 'T') + 'Z'; return s; } function monthRange(now) { const year = now.getFullYear(); const month = now.getMonth() + 1; const daysInMonth = new Date(year, month, 0).getDate(); const monthStr = String(month).padStart(2, '0'); return { year, month, start: `${year}-${monthStr}-01`, end: `${year}-${monthStr}-${String(daysInMonth).padStart(2, '0')}`, }; } // GET /api/status router.get('/', async (req, res) => { const runtimeState = getStatusRuntime(); const now = new Date(); const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000); let db; let database = { ok: false, connected: false, status: 'error', size_bytes: 0, size_mb: '0.00', last_modified: null, last_error: null, }; let stats = { active_sessions: null, users: null, active_bills: null, total_payments: null, }; let notifications = { ok: true, enabled: false, configured: false, smtp_configured: false, last_test_at: runtimeState.notifications.last_test_at, last_sent_at: null, last_error: runtimeState.notifications.last_error, }; let backups = { ok: true, enabled: false, configured: false, last_backup_at: null, last_backup_path: null, backup_count: null, keep_count: null, last_error: null, }; let tracker = { ok: true, current_year: now.getFullYear(), current_month: now.getMonth() + 1, bill_count: null, payment_count: null, bills_this_month: null, payments_this_month: null, overdue_count: null, skipped_count: null, last_error: null, }; try { db = getDb(); db.prepare('SELECT 1 AS ok').get(); const dbPath = db.name; let dbSizeBytes = 0; let dbLastModified = null; try { const stat = fs.statSync(dbPath); dbSizeBytes = stat.size; dbLastModified = stat.mtime.toISOString(); } catch { // The DB may be in-memory or on a volume that cannot be stat'ed. } database = { ok: true, connected: true, status: 'connected', // Filesystem path intentionally omitted — not safe to expose to all users size_bytes: dbSizeBytes, size_mb: (dbSizeBytes / 1024 / 1024).toFixed(2), last_modified: dbLastModified, last_error: null, }; } catch (err) { database.last_error = errorMessage(err); recordError('Database', err); } if (db) { try { stats = { active_sessions: db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE expires_at > datetime('now')").get().n, users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n, active_bills: db.prepare('SELECT COUNT(*) AS n FROM bills WHERE active = 1').get().n, total_payments: db.prepare('SELECT COUNT(*) AS n FROM payments').get().n, }; } catch (err) { recordError('Status Statistics', err); } try { const enabled = getSetting('notify_smtp_enabled') === 'true'; const host = getSetting('notify_smtp_host'); const sender = getSetting('notify_sender_address'); const port = getSetting('notify_smtp_port'); const username = getSetting('notify_smtp_username'); const password = getSetting('notify_smtp_password'); const configured = !!(host && sender && port && (!username || password)); const lastSent = db.prepare('SELECT MAX(sent_date) AS sent_date FROM notifications').get()?.sent_date || null; notifications = { ...notifications, ok: (!enabled || configured) && !notifications.last_error, enabled, configured, smtp_configured: configured, last_sent_at: lastSent, }; } catch (err) { notifications = { ...notifications, ok: false, last_error: errorMessage(err), }; recordError('Notifications Status', err); } try { const enabled = getSetting('backup_schedule_enabled') === 'true'; const keepCount = parseInt(getSetting('backup_keep_count') || '', 10); const managedBackups = listBackups(); const latestBackup = managedBackups[0] || null; const schedule = getScheduleStatus(); backups = { ok: true, enabled, configured: true, scheduled_enabled: schedule.enabled, scheduled_frequency: schedule.frequency, scheduled_time: schedule.time, next_backup_at: schedule.next_run_at, last_backup_at: latestBackup?.modified_at || null, last_backup_path: latestBackup?.id || null, last_backup_id: latestBackup?.id || null, backup_count: managedBackups.length, count: managedBackups.length, keep_count: schedule.retention_count ?? (Number.isInteger(keepCount) ? keepCount : null), retention_count: schedule.retention_count ?? (Number.isInteger(keepCount) ? keepCount : null), last_error: schedule.last_error || null, }; } catch (err) { backups = { ...backups, ok: false, last_error: errorMessage(err), }; recordError('Backups Status', err); } try { const range = monthRange(now); const todayDay = now.getDate(); const billCount = db.prepare('SELECT COUNT(*) AS n FROM bills WHERE active = 1').get().n; const paymentCount = db.prepare( 'SELECT COUNT(*) AS n FROM payments WHERE paid_date BETWEEN ? AND ? AND deleted_at IS NULL' ).get(range.start, range.end).n; const skippedCount = db.prepare( 'SELECT COUNT(*) AS n FROM monthly_bill_state WHERE year = ? AND month = ? AND is_skipped = 1' ).get(range.year, range.month).n; const overdueCount = db.prepare(` SELECT COUNT(*) AS n FROM bills b WHERE b.active = 1 AND COALESCE(NULLIF(b.cycle_type, ''), 'monthly') = 'monthly' AND CAST(b.due_day AS INTEGER) < ? AND NOT EXISTS ( SELECT 1 FROM payments p WHERE p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL ) AND NOT EXISTS ( SELECT 1 FROM monthly_bill_state mbs WHERE mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ? AND mbs.is_skipped = 1 ) `).get(todayDay, range.start, range.end, range.year, range.month).n; tracker = { ok: true, current_year: range.year, current_month: range.month, bill_count: billCount, payment_count: paymentCount, bills_this_month: billCount, payments_this_month: paymentCount, overdue_count: overdueCount, skipped_count: skippedCount, last_error: null, }; } catch (err) { tracker = { ...tracker, ok: false, last_error: errorMessage(err), }; recordError('Tracker Status', err); } } // Bank sync (SimpleFIN) status let bankSync = { enabled: false, running: false, source_count: 0, account_count: 0, transaction_count: 0, interval_hours: null, sync_days: null, last_sync_at: null, next_run_at: null, last_error: null, }; try { const config = getBankSyncConfig(); const workerStatus = getBankSyncWorkerStatus(); bankSync = { enabled: config.enabled, running: workerStatus.running, interval_hours: config.sync_interval_hours, sync_days: config.sync_days, source_count: 0, account_count: 0, transaction_count: 0, last_sync_at: null, next_run_at: workerStatus.next_run_at, last_error: null, }; if (db) { const sourceRow = db.prepare(` SELECT COUNT(*) AS source_count, MAX(last_sync_at) AS last_sync_at FROM data_sources WHERE type = 'provider_sync' AND provider = 'simplefin' `).get(); const accountRow = db.prepare(` SELECT COUNT(fa.id) AS account_count FROM financial_accounts fa INNER JOIN data_sources ds ON ds.id = fa.data_source_id WHERE ds.type = 'provider_sync' AND ds.provider = 'simplefin' `).get(); const txRow = db.prepare(` SELECT COUNT(t.id) AS transaction_count FROM transactions t INNER JOIN data_sources ds ON ds.id = t.data_source_id WHERE ds.type = 'provider_sync' AND ds.provider = 'simplefin' `).get(); const errorRow = db.prepare(` SELECT last_error FROM data_sources WHERE type = 'provider_sync' AND provider = 'simplefin' AND status = 'error' AND last_error IS NOT NULL ORDER BY updated_at DESC LIMIT 1 `).get(); bankSync = { ...bankSync, source_count: sourceRow.source_count ?? 0, account_count: accountRow.account_count ?? 0, transaction_count: txRow.transaction_count ?? 0, last_sync_at: toIso(sourceRow.last_sync_at), last_error: errorRow?.last_error || null, }; } } catch (err) { recordError('Bank Sync Status', err); } // Cleanup status — safe read-only summary from settings, no paths or secrets let cleanup = { ok: true, last_run_at: null, last_result: null }; try { const raw = getSetting('cleanup_last_result'); cleanup = { ok: true, last_run_at: toIso(getSetting('cleanup_last_run_at')), last_result: raw ? JSON.parse(raw) : null, }; } catch { /* non-fatal */ } const memoryMb = Number((process.memoryUsage().rss / 1024 / 1024).toFixed(1)); const runtime = { ok: true, node: process.version, node_version: process.version, platform: process.platform, arch: process.arch, uptime_seconds: uptimeSeconds, memory_mb: memoryMb, }; const server = { ok: true, time: now.toISOString(), now: now.toISOString(), today: now.toISOString().slice(0, 10), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || process.env.TZ || null, utc_offset: -now.getTimezoneOffset() / 60, env: process.env.NODE_ENV || 'development', }; const worker = { ok: !runtimeState.worker.last_error, enabled: runtimeState.worker.enabled, running: runtimeState.worker.running, started_at: runtimeState.worker.started_at, last_run_at: runtimeState.worker.last_run_at, last_success_at: runtimeState.worker.last_run_at, next_run_at: runtimeState.worker.next_run_at, last_error: runtimeState.worker.last_error, }; // Update check — non-blocking; uses cached result if available let update = { current_version: pkg.version, latest_version: null, up_to_date: null, has_update: false, error: null, last_checked_at: null }; try { update = await checkForUpdates(); } catch { /* non-fatal */ } const recentErrors = getStatusRuntime().recentErrors; const ok = database.ok && tracker.ok; res.json({ ok, app: { name: pkg.name, version: pkg.version, environment: server.env, uptime_seconds: uptimeSeconds, }, runtime, database, db: database, stats, statistics: stats, worker, notifications, backups, backup: backups, server, tracker, bank_sync: bankSync, simplefin: bankSync, cleanup, update, recent_errors: recentErrors, errors: recentErrors, version: pkg.version, environment: server.env, uptime_seconds: uptimeSeconds, node_version: process.version, platform: process.platform, hostname: os.hostname(), }); }); module.exports = router;