BillTracker/routes/status.js

385 lines
12 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
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');
2026-05-14 21:00:07 -05:00
const { checkForUpdates } = require('../services/updateCheckService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
2026-05-03 19:51:57 -05:00
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);
}
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
2026-05-14 21:00:07 -05:00
router.get('/', async (req, res) => {
2026-05-03 19:51:57 -05:00
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_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();
2026-05-03 19:51:57 -05:00
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;
2026-05-03 19:51:57 -05:00
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,
2026-05-03 19:51:57 -05:00
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: sourceRow.last_sync_at || null,
last_error: errorRow?.last_error || null,
};
}
} catch (err) {
recordError('Bank Sync Status', err);
}
2026-05-03 19:51:57 -05:00
// 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: getSetting('cleanup_last_run_at') || null,
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,
};
2026-05-14 21:00:07 -05:00
// 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 */ }
2026-05-03 19:51:57 -05:00
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,
2026-05-03 19:51:57 -05:00
cleanup,
2026-05-14 21:00:07 -05:00
update,
2026-05-03 19:51:57 -05:00
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;