From 9a9f890494e6f34d850765e986f401bec9a5463c Mon Sep 17 00:00:00 2001 From: mpuckett Date: Mon, 8 Jun 2026 12:25:47 -0500 Subject: [PATCH] Updated Logging System to Include Application Logs --- server/app.js | 18 ++++ server/logger.js | 37 +++++++ server/routes/admin.js | 7 ++ server/services/accessControl.js | 1 + server/services/appLogs.js | 58 +++++++++++ server/services/notifications.js | 22 +++-- server/services/teams.js | 7 +- server/services/webhooks.js | 8 +- server/smoke-test.js | 12 +++ src/App.vue | 3 + src/components/AppLogsSettings.vue | 151 +++++++++++++++++++++++++++++ src/constants.js | 1 + src/views/AdminView.vue | 10 +- 13 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 server/logger.js create mode 100644 server/services/appLogs.js create mode 100644 src/components/AppLogsSettings.vue diff --git a/server/app.js b/server/app.js index bfee039..4e0d716 100644 --- a/server/app.js +++ b/server/app.js @@ -5,6 +5,7 @@ moment.locale('en'); const express = require('express'); const path = require('path'); const { DB_PATH, initialize } = require('./db'); +const { logEvent } = require('./logger'); const { createCorsMiddleware } = require('./middleware/cors'); const { requireAuth } = require('./middleware/auth'); const { resolveCompany } = require('./middleware/company'); @@ -40,6 +41,23 @@ function createApp() { app.use(express.json({ limit: '12mb' })); console.log(`${moment().format()} ✅ Express app initialized with JSON body parsing and CORS middleware`); + // HTTP access log — one event per API request (method, path, status, duration). Skips the health + // probe and the log-viewer endpoint itself so the viewer doesn't generate noise about itself. + app.use((req, res, next) => { + if (!req.path.startsWith('/api') || req.path === '/api/health' || req.path.startsWith('/api/app-logs')) return next(); + const started = Date.now(); + res.on('finish', () => { + logEvent('http', `${req.method} ${req.originalUrl.split('?')[0]} → ${res.statusCode}`, { + method: req.method, + path: req.originalUrl.split('?')[0], + status: res.statusCode, + duration_ms: Date.now() - started, + ip: req.ip + }, res.statusCode >= 500 ? 'error' : (res.statusCode >= 400 ? 'warn' : 'info')); + }); + return next(); + }); + app.get('/api/health', (req, res) => { res.json({ ok: true, app: 'DepreCore', database: DB_PATH }); }); diff --git a/server/logger.js b/server/logger.js new file mode 100644 index 0000000..d181c6d --- /dev/null +++ b/server/logger.js @@ -0,0 +1,37 @@ +const path = require('path'); +const fs = require('fs'); +const winston = require('winston'); +const moment = require('moment'); + +// Application/event logger (distinct from the DB audit trail, which records user data changes). This +// captures operational events — HTTP requests, and outbound integration activity (SMTP, webhook, Teams, +// ServiceNow, Workday) — as JSON lines in data/logs/app.log (size-rotated), readable by the Application +// Logs admin screen. A console transport keeps a readable line on stdout for local development. + +const DATA_DIR = process.env.DEPRECORE_DATA_DIR || path.join(process.cwd(), 'data'); +const LOG_DIR = path.join(DATA_DIR, 'logs'); +fs.mkdirSync(LOG_DIR, { recursive: true }); +const LOG_FILE = path.join(LOG_DIR, 'app.log'); + +const logger = winston.createLogger({ + level: process.env.DEPRECORE_LOG_LEVEL || 'info', + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [ + new winston.transports.File({ filename: LOG_FILE, maxsize: 5 * 1024 * 1024, maxFiles: 5, tailable: true }), + new winston.transports.Console({ + format: winston.format.printf((info) => `${moment().format()} [${info.category || 'system'}] ${info.level}: ${info.message}`) + }) + ] +}); + +// Structured event logger. `category` groups the event (http | smtp | webhook | teams | servicenow | +// workday | system); `meta` is arbitrary structured detail. Never throws — logging must not break a flow. +function logEvent(category, message, meta = {}, level = 'info') { + try { + logger.log({ level, category: category || 'system', message, ...meta }); + } catch { + // swallow — a logging failure should never affect the request it describes + } +} + +module.exports = { logger, logEvent, LOG_FILE, LOG_DIR }; diff --git a/server/routes/admin.js b/server/routes/admin.js index da99ed0..b8ce898 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -6,6 +6,7 @@ const { CAPABILITIES } = require('../services/accessControl'); const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles'); const passwordPolicy = require('../services/passwordPolicy'); const auditTrail = require('../services/auditTrail'); +const appLogs = require('../services/appLogs'); const router = express.Router(); @@ -269,6 +270,12 @@ router.get('/audit-logs', requireCapability('admin.audit'), (req, res) => { res.json({ ...result, facets: auditTrail.facets() }); }); +// ---- Application / event logs (Winston) ------------------------------------- + +router.get('/app-logs', requireCapability('admin.logs'), (req, res) => { + res.json(appLogs.readLogs({ category: req.query.category || null, level: req.query.level || null, q: req.query.q || null }, req.query.limit)); +}); + router.get('/audit-logs/export', requireCapability('admin.audit'), (req, res) => { const rows = auditTrail.queryAll(auditFilters(req.query)); audit(req.user.id, 'export', 'audit_logs', 'csv', null, { count: rows.length, filters: auditFilters(req.query) }); diff --git a/server/services/accessControl.js b/server/services/accessControl.js index a217193..65bae02 100644 --- a/server/services/accessControl.js +++ b/server/services/accessControl.js @@ -28,6 +28,7 @@ const CAPABILITIES = [ { key: 'admin.roles', label: 'Manage roles', group: 'Administration' }, { key: 'admin.security', label: 'Manage password & lockout policy', group: 'Administration' }, { key: 'admin.audit', label: 'View activity & audit trail', group: 'Administration' }, + { key: 'admin.logs', label: 'View application logs', group: 'Administration' }, { key: 'admin.settings', label: 'Manage application settings', group: 'Administration' }, { key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' } ]; diff --git a/server/services/appLogs.js b/server/services/appLogs.js new file mode 100644 index 0000000..fa85a89 --- /dev/null +++ b/server/services/appLogs.js @@ -0,0 +1,58 @@ +const fs = require('fs'); +const { LOG_FILE } = require('../logger'); + +// Read the application/event log (JSON lines written by the Winston file transport) for the admin +// Application Logs viewer. Reads the current log file, parses the most recent lines, and returns them +// newest-first with optional category/level/search filtering plus the available filter facets. + +const SCAN_LINES = 8000; // how many trailing lines to parse (the file is size-rotated, ~5MB) + +function readLogs(filters = {}, limit = 500) { + let lines = []; + try { + lines = fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean); + } catch { + return { logs: [], total: 0, categories: [], levels: [] }; + } + + // Parse the trailing window, newest first. + const parsed = []; + for (let i = lines.length - 1, scanned = 0; i >= 0 && scanned < SCAN_LINES; i -= 1, scanned += 1) { + try { + const obj = JSON.parse(lines[i]); + parsed.push({ + timestamp: obj.timestamp || null, + level: obj.level || 'info', + category: obj.category || 'system', + message: obj.message || '', + meta: stripMeta(obj) + }); + } catch { + // skip malformed lines + } + } + + const categories = [...new Set(parsed.map((p) => p.category))].sort(); + const levels = [...new Set(parsed.map((p) => p.level))].sort(); + + let filtered = parsed; + if (filters.category) filtered = filtered.filter((p) => p.category === filters.category); + if (filters.level) filtered = filtered.filter((p) => p.level === filters.level); + if (filters.q) { + const needle = String(filters.q).toLowerCase(); + filtered = filtered.filter((p) => `${p.message} ${JSON.stringify(p.meta)}`.toLowerCase().includes(needle)); + } + + return { logs: filtered.slice(0, Math.min(2000, Number(limit) || 500)), total: filtered.length, categories, levels }; +} + +// Everything on a log line except the standard fields is "meta" (the structured detail). +function stripMeta(obj) { + const meta = {}; + for (const [key, value] of Object.entries(obj)) { + if (!['timestamp', 'level', 'category', 'message'].includes(key)) meta[key] = value; + } + return meta; +} + +module.exports = { readLogs }; diff --git a/server/services/notifications.js b/server/services/notifications.js index 28afaff..855da3f 100644 --- a/server/services/notifications.js +++ b/server/services/notifications.js @@ -1,5 +1,6 @@ const nodemailer = require('nodemailer'); const { all, audit, now, run } = require('../db'); +const { logEvent } = require('../logger'); function readSettings() { const rows = all('SELECT key, value FROM app_settings'); @@ -95,13 +96,20 @@ async function sendMail({ to, subject, html, text }) { throw Object.assign(new Error('No recipients are configured'), { status: 400 }); } const transport = getTransport(); - return transport.sendMail({ - from: c.from || c.user, - to: recipients.join(', '), - subject, - html, - text - }); + try { + const info = await transport.sendMail({ + from: c.from || c.user, + to: recipients.join(', '), + subject, + html, + text + }); + logEvent('smtp', `Email sent: "${subject}" to ${recipients.length} recipient(s)`, { subject, recipients: recipients.length, host: c.host, messageId: info.messageId }); + return info; + } catch (error) { + logEvent('smtp', `Email send failed: "${subject}" — ${error.message}`, { subject, recipients: recipients.length, host: c.host }, 'error'); + throw error; + } } async function sendTestEmail(to, userId) { diff --git a/server/services/teams.js b/server/services/teams.js index 860f62e..4c0caac 100644 --- a/server/services/teams.js +++ b/server/services/teams.js @@ -3,6 +3,7 @@ // Office 365 connector ("MessageCard"). Best-effort delivery — failures are counted, never thrown, // so a bad Teams endpoint can't break the alert cycle. const { all, audit, now, run } = require('../db'); +const { logEvent } = require('../logger'); const TIMEOUT_MS = 10000; const FORMATS = ['adaptive', 'messagecard']; @@ -161,8 +162,10 @@ async function deliverAlerts(alerts) { if (!relevant.length) return { attempted: false, delivered: 0, failed: 0 }; try { const result = await postPayload(c.webhookUrl, buildPayload(relevant, c)); + logEvent('teams', `Posted ${relevant.length} alert(s) to Teams (HTTP ${result.status})`, { count: relevant.length, status: result.status, format: c.format }, result.ok ? 'info' : 'warn'); return { attempted: true, delivered: result.ok ? 1 : 0, failed: result.ok ? 0 : 1, status: result.status, count: relevant.length }; - } catch { + } catch (error) { + logEvent('teams', `Teams alert post failed: ${error.message}`, { count: relevant.length }, 'error'); return { attempted: true, delivered: 0, failed: 1 }; } } @@ -177,8 +180,10 @@ async function sendTest(userId) { try { result = await postPayload(c.webhookUrl, payload); } catch (error) { + logEvent('teams', `Test card request failed: ${error.message}`, {}, 'error'); throw Object.assign(new Error(`Teams request failed: ${error.message}`), { status: 400 }); } + logEvent('teams', `Test card sent (HTTP ${result.status})`, { status: result.status, format: c.format }, result.ok ? 'info' : 'warn'); audit(userId, 'send', 'teams_test', null, null, { status: result.status }); if (!result.ok) throw Object.assign(new Error(`Teams endpoint returned HTTP ${result.status}`), { status: 400 }); return { ok: true, status: result.status }; diff --git a/server/services/webhooks.js b/server/services/webhooks.js index c33510f..50a8acb 100644 --- a/server/services/webhooks.js +++ b/server/services/webhooks.js @@ -4,6 +4,7 @@ const moment = require('moment'); const crypto = require('crypto'); const { audit } = require('../db'); +const { logEvent } = require('../logger'); const { getConfig } = require('./notifications'); const TIMEOUT_MS = 10000; @@ -74,11 +75,12 @@ async function deliverAlerts(alerts) { try { // Note: we build the payload for the alert using the buildPayload function, which creates a stable and flat JSON object with relevant information about the alert, and then we post it to the webhook URL using the postJson function; if the postJson function returns an OK response, we increment the delivered count, otherwise we increment the failed count, allowing us to track the success and failure of each delivery attempt. const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert)); - if (result.ok) delivered += 1; - else failed += 1; + if (result.ok) { delivered += 1; logEvent('webhook', `Alert delivered: ${alert.title}`, { url: c.webhookUrl, status: result.status, alert_id: alert.id }); } + else { failed += 1; logEvent('webhook', `Alert delivery returned HTTP ${result.status}: ${alert.title}`, { url: c.webhookUrl, status: result.status, alert_id: alert.id }, 'warn'); } } catch (error) { console.log(`${moment().format()} ❌ Failed to deliver alert to webhook: ${error.message}`); failed += 1; + logEvent('webhook', `Alert delivery failed: ${alert.title} — ${error.message}`, { url: c.webhookUrl, alert_id: alert.id }, 'error'); } } console.log(`${moment().format()} 🚚 Webhook delivery complete: ${delivered} delivered, ${failed} failed`); @@ -108,9 +110,11 @@ async function sendTestWebhook(userId) { result = await postJson(c.webhookUrl, c.webhookSecret, payload); } catch (error) { console.log(`${moment().format()} ❌ Test webhook failed: ${error.message}`); + logEvent('webhook', `Test webhook request failed: ${error.message}`, { url: c.webhookUrl }, 'error'); throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 }); } console.log(`${moment().format()} 📡 Test webhook sent successfully to: ${c.webhookUrl}`) ; + logEvent('webhook', `Test webhook sent (HTTP ${result.status})`, { url: c.webhookUrl, status: result.status }, result.ok ? 'info' : 'warn'); audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status }); if (!result.ok) { // Note: if the response from the webhook endpoint is not OK (i.e., not in the 200-299 range), we log an error message with the HTTP status code returned by the endpoint and throw an error indicating that the webhook endpoint returned a non-OK status; this allows us to provide clear feedback about why the test webhook failed and helps users understand that their webhook endpoint may be rejecting the request or encountering an error when processing it. diff --git a/server/smoke-test.js b/server/smoke-test.js index dc4f78f..1a7f325 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -1869,6 +1869,18 @@ async function main() { if (!acctsB.length) throw new Error('Company B chart of accounts was not seeded'); if (acctsB.some((a) => a.code === `1501-${suffix}`)) throw new Error('Company B sees company A GL account'); + // ---- Application (Winston) event logs -------------------------------------- + const appLog = await request('/api/app-logs', { headers: auth }); + if (!appLog.logs.length) throw new Error('Application logs were empty'); + if (!appLog.categories.includes('http')) throw new Error('Application logs did not capture HTTP events'); + const logSample = appLog.logs[0]; + if (!logSample.timestamp || !logSample.level || !logSample.category) throw new Error('Application log entry was missing fields'); + const httpLogs = await request('/api/app-logs?category=http', { headers: auth }); + if (!httpLogs.logs.length || httpLogs.logs.some((l) => l.category !== 'http')) throw new Error('Application log category filter failed'); + // Requires the admin.logs capability — a viewer-role user is refused. + const logForbidden = await fetch(`${baseUrl}/api/app-logs`, { headers: limitedAuth }); + if (logForbidden.status !== 403) throw new Error('Application logs should require the admin.logs capability'); + console.log('Smoke test passed'); } diff --git a/src/App.vue b/src/App.vue index 528f362..1b44cc4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -540,6 +540,7 @@ export default { 'admin-users': 'User accounts, teams, and role permissions', 'admin-security': 'Password rules, account lockout, and complexity policy', 'admin-audit': 'Complete audit trail of all user activity, with export', + 'admin-logs': 'Application/event log — HTTP, SMTP, webhook, and Teams activity', 'admin-config': 'Application settings, maintenance defaults, and tax rule sets', 'admin-integrations': 'Email, webhooks, ServiceNow, and Workday connectors' }[this.activeView]; @@ -563,6 +564,7 @@ export default { 'admin-users': 'Users & Teams', 'admin-security': 'Password & Security', 'admin-audit': 'Activity & Audit Trail', + 'admin-logs': 'Application Logs', 'admin-config': 'Application Configuration', 'admin-integrations': 'Integrations' }[this.activeView]; @@ -627,6 +629,7 @@ export default { 'admin-users': ['admin.users'], 'admin-security': ['admin.security'], 'admin-audit': ['admin.audit'], + 'admin-logs': ['admin.logs'], 'admin-config': ['admin.settings', 'config.manage'], 'admin-integrations': ['admin.integrations'] }; diff --git a/src/components/AppLogsSettings.vue b/src/components/AppLogsSettings.vue new file mode 100644 index 0000000..8b54bfc --- /dev/null +++ b/src/components/AppLogsSettings.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/constants.js b/src/constants.js index 1cfd272..d31165b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -173,6 +173,7 @@ export const navItems = [ { key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' }, { key: 'admin-security', label: 'Password & Security', icon: 'mdi-shield-key-outline' }, { key: 'admin-audit', label: 'Activity & Audit Trail', icon: 'mdi-history' }, + { key: 'admin-logs', label: 'Application Logs', icon: 'mdi-text-box-search-outline' }, { key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' }, { key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' } ] diff --git a/src/views/AdminView.vue b/src/views/AdminView.vue index e631517..42ec0c9 100644 --- a/src/views/AdminView.vue +++ b/src/views/AdminView.vue @@ -170,6 +170,11 @@ + + +