From a9d10921f3748db03b82ebb73333b88b6e61f547 Mon Sep 17 00:00:00 2001 From: Muhammad Yousaf Iqbal Date: Thu, 23 Jul 2026 09:29:57 -0400 Subject: [PATCH 1/2] backend --- server/server/audit/audit_logger.js | 78 ++++++++++++++++++ server/server/database/db_config.js | 1 + .../server/database/table_sql/audit_log.sql | 12 +++ .../database/table_sql/create_all_tables.sql | 3 +- server/server/routing/db_routes.js | 80 ++++++++++++++++++- server/server/routing/index.js | 3 +- 6 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 server/server/audit/audit_logger.js create mode 100644 server/server/database/table_sql/audit_log.sql diff --git a/server/server/audit/audit_logger.js b/server/server/audit/audit_logger.js new file mode 100644 index 00000000..b9feb1eb --- /dev/null +++ b/server/server/audit/audit_logger.js @@ -0,0 +1,78 @@ +const Logger = require("../logger"); +const DB_CONFIG = require("../database/db_config"); + +const ACTION_TYPES = { + CREATE: "CREATED", + UPDATE: "UPDATED", + DEACTIVATE: "DEACTIVATED", + REACTIVATE: "REACTIVATED", +}; + +module.exports = (db) => { + function getActor(req) { + const system_id = req.user ? req.user.system_id : null; + const mock_id = req.user && req.user.mock ? req.user.mock.system_id : null; + return { system_id, mock_id }; + } + + function actorLabel(req) { + if (!req.user) return "Unknown user"; + const name = [req.user.fname, req.user.lname].filter(Boolean).join(" "); + return name ? `${name} (${req.user.system_id})` : req.user.system_id; + } + + async function record( + req, + { actionType, entityType, entityId, message, details }, + ) { + const { system_id, mock_id } = getActor(req); + + const insertQuery = ` + INSERT INTO ${DB_CONFIG.tableNames.audit_log} + (system_id, mock_id, action_type, entity_type, entity_id, message, details) + VALUES (?, ?, ?, ?, ?, ?, ?) + `; + + const params = [ + system_id, + mock_id, + actionType, + entityType, + entityId === undefined || entityId === null ? null : String(entityId), + message, + details === undefined ? null : JSON.stringify(details), + ]; + + try { + await db.query(insertQuery, params); + } catch (err) { + Logger.error(`Failed to write audit log entry: ${err.message}`); + } + } + + function humanizeFieldName(field) { + return field.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + } + + function displayValue(value) { + if (value === null || value === undefined || value === "") return "(empty)"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); + } + + function summarizeChangedFields(changedFields, excludeFields = []) { + if (!changedFields || typeof changedFields !== "object") return ""; + + return Object.keys(changedFields) + .filter((field) => !excludeFields.includes(field)) + .map((field) => { + const [before, after] = changedFields[field]; + return `${humanizeFieldName(field)}: "${displayValue(before)}" → "${displayValue(after)}"`; + }) + .join("; "); + } + + return { record, actorLabel, summarizeChangedFields, ACTION_TYPES }; +}; + +module.exports.ACTION_TYPES = ACTION_TYPES; diff --git a/server/server/database/db_config.js b/server/server/database/db_config.js index 35084b61..694a58a5 100644 --- a/server/server/database/db_config.js +++ b/server/server/database/db_config.js @@ -19,6 +19,7 @@ module.exports = { project_coaches: "project_coaches", sponsors: "sponsors", error_log: "error_log", + audit_log: "audit_log", }, senior_project_proposal_keys: { title: "Title", diff --git a/server/server/database/table_sql/audit_log.sql b/server/server/database/table_sql/audit_log.sql new file mode 100644 index 00000000..bf6ca3f1 --- /dev/null +++ b/server/server/database/table_sql/audit_log.sql @@ -0,0 +1,12 @@ +CREATE TABLE audit_log ( + audit_log_id INTEGER PRIMARY KEY AUTOINCREMENT, + audit_datetime DATETIME DEFAULT CURRENT_TIMESTAMP, + system_id TEXT, + mock_id TEXT, + action_type TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT, + message TEXT NOT NULL, + details TEXT, + FOREIGN KEY (system_id) REFERENCES users(system_id) +); diff --git a/server/server/database/table_sql/create_all_tables.sql b/server/server/database/table_sql/create_all_tables.sql index 6aa28bbe..aeb9a297 100644 --- a/server/server/database/table_sql/create_all_tables.sql +++ b/server/server/database/table_sql/create_all_tables.sql @@ -9,4 +9,5 @@ .read table_sql/users.sql .read table_sql/sponsor_notes.sql .read table_sql/page_html.sql -.read table_sql/error_log.sql \ No newline at end of file +.read table_sql/error_log.sql +.read table_sql/audit_log.sql diff --git a/server/server/routing/db_routes.js b/server/server/routing/db_routes.js index 1f0cdd86..ff91b03b 100644 --- a/server/server/routing/db_routes.js +++ b/server/server/routing/db_routes.js @@ -91,7 +91,7 @@ const ACTION_TARGETS = { }; // Routes -module.exports = (db) => { +module.exports = (db, AuditLog) => { /** * /getAllUsersForLogin ENDPOINT SHOULD ONLY BE HIT IN DEVELOPMENT ONLY * @@ -1730,6 +1730,27 @@ module.exports = (db) => { db.query(deleteCoachesSQL), ]) .then((values) => { + let changedFields = {}; + try { + changedFields = body.changed_fields + ? JSON.parse(body.changed_fields) + : {}; + } catch (err) { + changedFields = {}; + } + + const changeSummary = AuditLog.summarizeChangedFields(changedFields); + const baseMessage = `${AuditLog.actorLabel(req)} updated project ${body.project_id} (${body.title})`; + + AuditLog.record(req, { + actionType: AuditLog.ACTION_TYPES.UPDATE, + entityType: "project", + entityId: body.project_id, + message: changeSummary + ? `${baseMessage} — ${changeSummary}` + : baseMessage, + details: changedFields, + }); return res.sendStatus(200); }) .catch((err) => { @@ -3404,6 +3425,47 @@ module.exports = (db) => { db.query(updateQuery, params) .then(() => { + let changedFields = {}; + try { + changedFields = body.changed_fields + ? JSON.parse(body.changed_fields) + : {}; + } catch (err) { + changedFields = {}; + } + + let auditActionType = AuditLog.ACTION_TYPES.UPDATE; + let auditVerb = "updated"; + + if ("date_deleted" in changedFields) { + const [beforeRaw, afterRaw] = changedFields.date_deleted; + const isEmpty = (v) => v === "" || v === undefined || v === null; + const wasActive = isEmpty(beforeRaw); + const isActiveNow = isEmpty(afterRaw); + + if (wasActive && !isActiveNow) { + auditActionType = AuditLog.ACTION_TYPES.DEACTIVATE; + auditVerb = "deactivated"; + } else if (!wasActive && isActiveNow) { + auditActionType = AuditLog.ACTION_TYPES.REACTIVATE; + auditVerb = "reactivated"; + } + + delete changedFields.date_deleted; + } + + const changeSummary = AuditLog.summarizeChangedFields(changedFields); + const baseMessage = `${AuditLog.actorLabel(req)} ${auditVerb} action ${body.action_id} (${body.action_title})`; + + AuditLog.record(req, { + actionType: auditActionType, + entityType: "action", + entityId: body.action_id, + message: changeSummary + ? `${baseMessage} — ${changeSummary}` + : baseMessage, + details: changedFields, + }); return res.status(200).send(); }) .catch((err) => { @@ -3966,6 +4028,22 @@ module.exports = (db) => { db.query(updateQuery, params) .then(() => { + return db.query( + `SELECT action_id FROM actions + WHERE semester = ? AND action_title = ? AND start_date = ? AND due_date = ? + ORDER BY action_id DESC LIMIT 1`, + [body.semester, body.action_title, body.start_date, body.due_date], + ); + }) + .then((rows) => { + const newActionId = rows && rows[0] ? rows[0].action_id : null; + AuditLog.record(req, { + actionType: AuditLog.ACTION_TYPES.CREATE, + entityType: "action", + entityId: newActionId, + message: `${AuditLog.actorLabel(req)} created action ${newActionId} (${body.action_title})`, + details: body, + }); return res.status(200).send(); }) .catch((err) => { diff --git a/server/server/routing/index.js b/server/server/routing/index.js index 60381acc..f94e688c 100644 --- a/server/server/routing/index.js +++ b/server/server/routing/index.js @@ -10,9 +10,10 @@ require("../config/passport"); const router = require("express").Router(); const DBHandler = require("../database/db"); let db = new DBHandler(); +const AuditLog = require("../audit/audit_logger")(db); const saml_router = require("./saml_routes")(router, db); -const db_router = require("./db_routes")(db); +const db_router = require("./db_routes")(db, AuditLog); const ai_router = require("./ai_routes")(router); // Database routes From c406a210ffe5ea72b96380053c2b33f1812c5fdb Mon Sep 17 00:00:00 2001 From: Muhammad Yousaf Iqbal Date: Sun, 26 Jul 2026 14:02:43 -0400 Subject: [PATCH 2/2] backend --- server/server/audit/audit_logger.js | 105 +++++-------- .../server/database/table_sql/audit_log.sql | 2 +- .../database/test_data/audit_log_dummy.sql | 62 ++++++++ server/server/routing/db_routes.js | 145 +++++++++--------- server/server/routing/index.js | 3 +- server/server/util/format_utils.js | 41 +++++ 6 files changed, 219 insertions(+), 139 deletions(-) create mode 100644 server/server/database/test_data/audit_log_dummy.sql create mode 100644 server/server/util/format_utils.js diff --git a/server/server/audit/audit_logger.js b/server/server/audit/audit_logger.js index b9feb1eb..7fc2b4d1 100644 --- a/server/server/audit/audit_logger.js +++ b/server/server/audit/audit_logger.js @@ -1,5 +1,13 @@ const Logger = require("../logger"); +const DBHandler = require("../database/db"); const DB_CONFIG = require("../database/db_config"); +const { + getActor, + actorLabel, + summarizeChangedFields, +} = require("../util/format_utils"); + +const db = new DBHandler(); const ACTION_TYPES = { CREATE: "CREATED", @@ -8,71 +16,36 @@ const ACTION_TYPES = { REACTIVATE: "REACTIVATED", }; -module.exports = (db) => { - function getActor(req) { - const system_id = req.user ? req.user.system_id : null; - const mock_id = req.user && req.user.mock ? req.user.mock.system_id : null; - return { system_id, mock_id }; - } - - function actorLabel(req) { - if (!req.user) return "Unknown user"; - const name = [req.user.fname, req.user.lname].filter(Boolean).join(" "); - return name ? `${name} (${req.user.system_id})` : req.user.system_id; +async function record( + req, + { actionType, entityType, entityId, message, details }, +) { + const { system_id, mock_id } = getActor(req); + + const insertQuery = ` + INSERT INTO ${DB_CONFIG.tableNames.audit_log} + (system_id, mock_id, action_type, entity_type, entity_id, message, details_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + `; + + const params = [ + system_id, + mock_id, + actionType, + entityType, + entityId === undefined || entityId === null ? null : String(entityId), + message, + details === undefined ? null : JSON.stringify(details), + ]; + + try { + await db.query(insertQuery, params); + } catch (err) { + Logger.error( + `Failed to write audit log entry for ${system_id || "unknown user"} ` + + `(${actionType} ${entityType}${entityId ? ":" + entityId : ""}): ${err.message}`, + ); } +} - async function record( - req, - { actionType, entityType, entityId, message, details }, - ) { - const { system_id, mock_id } = getActor(req); - - const insertQuery = ` - INSERT INTO ${DB_CONFIG.tableNames.audit_log} - (system_id, mock_id, action_type, entity_type, entity_id, message, details) - VALUES (?, ?, ?, ?, ?, ?, ?) - `; - - const params = [ - system_id, - mock_id, - actionType, - entityType, - entityId === undefined || entityId === null ? null : String(entityId), - message, - details === undefined ? null : JSON.stringify(details), - ]; - - try { - await db.query(insertQuery, params); - } catch (err) { - Logger.error(`Failed to write audit log entry: ${err.message}`); - } - } - - function humanizeFieldName(field) { - return field.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); - } - - function displayValue(value) { - if (value === null || value === undefined || value === "") return "(empty)"; - if (typeof value === "object") return JSON.stringify(value); - return String(value); - } - - function summarizeChangedFields(changedFields, excludeFields = []) { - if (!changedFields || typeof changedFields !== "object") return ""; - - return Object.keys(changedFields) - .filter((field) => !excludeFields.includes(field)) - .map((field) => { - const [before, after] = changedFields[field]; - return `${humanizeFieldName(field)}: "${displayValue(before)}" → "${displayValue(after)}"`; - }) - .join("; "); - } - - return { record, actorLabel, summarizeChangedFields, ACTION_TYPES }; -}; - -module.exports.ACTION_TYPES = ACTION_TYPES; +module.exports = { record, actorLabel, summarizeChangedFields, ACTION_TYPES }; diff --git a/server/server/database/table_sql/audit_log.sql b/server/server/database/table_sql/audit_log.sql index bf6ca3f1..5cf393d3 100644 --- a/server/server/database/table_sql/audit_log.sql +++ b/server/server/database/table_sql/audit_log.sql @@ -7,6 +7,6 @@ CREATE TABLE audit_log ( entity_type TEXT NOT NULL, entity_id TEXT, message TEXT NOT NULL, - details TEXT, + details_json TEXT, FOREIGN KEY (system_id) REFERENCES users(system_id) ); diff --git a/server/server/database/test_data/audit_log_dummy.sql b/server/server/database/test_data/audit_log_dummy.sql new file mode 100644 index 00000000..c6fb3a3c --- /dev/null +++ b/server/server/database/test_data/audit_log_dummy.sql @@ -0,0 +1,62 @@ +INSERT INTO + audit_log ( + audit_datetime, + system_id, + mock_id, + action_type, + entity_type, + entity_id, + message, + details_json + ) +VALUES + ( + DATETIME(DATE('now', '-6 days'), '09:15:00'), + 'glados', + NULL, + 'CREATED', + 'action', + '45', + 'Admin Account (glados) created action 45 (Team Name Submission)', + '{"semester":"2","action_title":"Team Name Submission","action_target":"team","start_date":"2026-01-20","due_date":"2026-02-01"}' + ), + ( + DATETIME(DATE('now', '-5 days'), '13:42:10'), + 'glados', + NULL, + 'UPDATED', + 'action', + '45', + 'Admin Account (glados) updated action 45 (Team Name Submission) — Due Date: "2026-02-01" → "2026-02-05"', + '{"due_date":["2026-02-01","2026-02-05"]}' + ), + ( + DATETIME(DATE('now', '-4 days'), '17:10:44'), + 'glados', + NULL, + 'DEACTIVATED', + 'action', + '45', + 'Admin Account (glados) deactivated action 45 (Team Name Submission)', + '{}' + ), + ( + DATETIME(DATE('now', '-3 days'), '08:05:22'), + 'glados', + NULL, + 'REACTIVATED', + 'action', + '45', + 'Admin Account (glados) reactivated action 45 (Team Name Submission)', + '{}' + ), + ( + DATETIME(DATE('now', '-2 days'), '11:30:00'), + 'glados', + NULL, + 'UPDATED', + 'project', + '1_groweasy', + 'Admin Account (glados) updated project 1_groweasy (GrowEasy) — Contact Email: "rachel.thompson@mail.com" → "rachel.thompson@mail.edu"', + '{"contact_email":["rachel.thompson@mail.com","rachel.thompson@mail.edu"]}' + ); diff --git a/server/server/routing/db_routes.js b/server/server/routing/db_routes.js index ff91b03b..5e617cd4 100644 --- a/server/server/routing/db_routes.js +++ b/server/server/routing/db_routes.js @@ -11,6 +11,7 @@ const fileSizeParser = require("filesize-parser"); const he = require("he"); const { convert } = require("html-to-text"); const redeployDatabase = require("../../db_setup"); +const AuditLog = require("../audit/audit_logger"); function humanFileSize(bytes, si = false, dp = 1) { const thresh = si ? 1000 : 1024; @@ -90,8 +91,78 @@ const ACTION_TARGETS = { PEER_EVALUATION: "peer_evaluation", }; +function recordProjectEditAudit(req, body) { + let changedFields = {}; + try { + changedFields = body.changed_fields ? JSON.parse(body.changed_fields) : {}; + } catch (err) { + changedFields = {}; + } + + const changeSummary = AuditLog.summarizeChangedFields(changedFields); + const baseMessage = `${AuditLog.actorLabel(req)} updated project ${body.project_id} (${body.title})`; + + return AuditLog.record(req, { + actionType: AuditLog.ACTION_TYPES.UPDATE, + entityType: "project", + entityId: body.project_id, + message: changeSummary ? `${baseMessage} — ${changeSummary}` : baseMessage, + details: changedFields, + }); +} + +function recordActionEditAudit(req, body) { + let changedFields = {}; + try { + changedFields = body.changed_fields ? JSON.parse(body.changed_fields) : {}; + } catch (err) { + changedFields = {}; + } + + let auditActionType = AuditLog.ACTION_TYPES.UPDATE; + let auditVerb = "updated"; + + if ("date_deleted" in changedFields) { + const [beforeRaw, afterRaw] = changedFields.date_deleted; + const isEmpty = (v) => v === "" || v === undefined || v === null; + const wasActive = isEmpty(beforeRaw); + const isActiveNow = isEmpty(afterRaw); + + if (wasActive && !isActiveNow) { + auditActionType = AuditLog.ACTION_TYPES.DEACTIVATE; + auditVerb = "deactivated"; + } else if (!wasActive && isActiveNow) { + auditActionType = AuditLog.ACTION_TYPES.REACTIVATE; + auditVerb = "reactivated"; + } + + delete changedFields.date_deleted; + } + + const changeSummary = AuditLog.summarizeChangedFields(changedFields); + const baseMessage = `${AuditLog.actorLabel(req)} ${auditVerb} action ${body.action_id} (${body.action_title})`; + + return AuditLog.record(req, { + actionType: auditActionType, + entityType: "action", + entityId: body.action_id, + message: changeSummary ? `${baseMessage} — ${changeSummary}` : baseMessage, + details: changedFields, + }); +} + +function recordActionCreateAudit(req, body, newActionId) { + return AuditLog.record(req, { + actionType: AuditLog.ACTION_TYPES.CREATE, + entityType: "action", + entityId: newActionId, + message: `${AuditLog.actorLabel(req)} created action ${newActionId} (${body.action_title})`, + details: body, + }); +} + // Routes -module.exports = (db, AuditLog) => { +module.exports = (db) => { /** * /getAllUsersForLogin ENDPOINT SHOULD ONLY BE HIT IN DEVELOPMENT ONLY * @@ -1730,27 +1801,7 @@ module.exports = (db, AuditLog) => { db.query(deleteCoachesSQL), ]) .then((values) => { - let changedFields = {}; - try { - changedFields = body.changed_fields - ? JSON.parse(body.changed_fields) - : {}; - } catch (err) { - changedFields = {}; - } - - const changeSummary = AuditLog.summarizeChangedFields(changedFields); - const baseMessage = `${AuditLog.actorLabel(req)} updated project ${body.project_id} (${body.title})`; - - AuditLog.record(req, { - actionType: AuditLog.ACTION_TYPES.UPDATE, - entityType: "project", - entityId: body.project_id, - message: changeSummary - ? `${baseMessage} — ${changeSummary}` - : baseMessage, - details: changedFields, - }); + recordProjectEditAudit(req, body); return res.sendStatus(200); }) .catch((err) => { @@ -3425,47 +3476,7 @@ module.exports = (db, AuditLog) => { db.query(updateQuery, params) .then(() => { - let changedFields = {}; - try { - changedFields = body.changed_fields - ? JSON.parse(body.changed_fields) - : {}; - } catch (err) { - changedFields = {}; - } - - let auditActionType = AuditLog.ACTION_TYPES.UPDATE; - let auditVerb = "updated"; - - if ("date_deleted" in changedFields) { - const [beforeRaw, afterRaw] = changedFields.date_deleted; - const isEmpty = (v) => v === "" || v === undefined || v === null; - const wasActive = isEmpty(beforeRaw); - const isActiveNow = isEmpty(afterRaw); - - if (wasActive && !isActiveNow) { - auditActionType = AuditLog.ACTION_TYPES.DEACTIVATE; - auditVerb = "deactivated"; - } else if (!wasActive && isActiveNow) { - auditActionType = AuditLog.ACTION_TYPES.REACTIVATE; - auditVerb = "reactivated"; - } - - delete changedFields.date_deleted; - } - - const changeSummary = AuditLog.summarizeChangedFields(changedFields); - const baseMessage = `${AuditLog.actorLabel(req)} ${auditVerb} action ${body.action_id} (${body.action_title})`; - - AuditLog.record(req, { - actionType: auditActionType, - entityType: "action", - entityId: body.action_id, - message: changeSummary - ? `${baseMessage} — ${changeSummary}` - : baseMessage, - details: changedFields, - }); + recordActionEditAudit(req, body); return res.status(200).send(); }) .catch((err) => { @@ -4037,13 +4048,7 @@ module.exports = (db, AuditLog) => { }) .then((rows) => { const newActionId = rows && rows[0] ? rows[0].action_id : null; - AuditLog.record(req, { - actionType: AuditLog.ACTION_TYPES.CREATE, - entityType: "action", - entityId: newActionId, - message: `${AuditLog.actorLabel(req)} created action ${newActionId} (${body.action_title})`, - details: body, - }); + recordActionCreateAudit(req, body, newActionId); return res.status(200).send(); }) .catch((err) => { diff --git a/server/server/routing/index.js b/server/server/routing/index.js index f94e688c..60381acc 100644 --- a/server/server/routing/index.js +++ b/server/server/routing/index.js @@ -10,10 +10,9 @@ require("../config/passport"); const router = require("express").Router(); const DBHandler = require("../database/db"); let db = new DBHandler(); -const AuditLog = require("../audit/audit_logger")(db); const saml_router = require("./saml_routes")(router, db); -const db_router = require("./db_routes")(db, AuditLog); +const db_router = require("./db_routes")(db); const ai_router = require("./ai_routes")(router); // Database routes diff --git a/server/server/util/format_utils.js b/server/server/util/format_utils.js new file mode 100644 index 00000000..3ac30064 --- /dev/null +++ b/server/server/util/format_utils.js @@ -0,0 +1,41 @@ +function getActor(req) { + const system_id = req.user ? req.user.system_id : null; + const mock_id = req.user && req.user.mock ? req.user.mock.system_id : null; + return { system_id, mock_id }; +} + +function actorLabel(req) { + if (!req.user) return "Unknown user"; + const name = [req.user.fname, req.user.lname].filter(Boolean).join(" "); + return name ? `${name} (${req.user.system_id})` : req.user.system_id; +} + +function humanizeFieldName(field) { + return field.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function displayValue(value) { + if (value === null || value === undefined || value === "") return "(empty)"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function summarizeChangedFields(changedFields, excludeFields = []) { + if (!changedFields || typeof changedFields !== "object") return ""; + + return Object.keys(changedFields) + .filter((field) => !excludeFields.includes(field)) + .map((field) => { + const [before, after] = changedFields[field]; + return `${humanizeFieldName(field)}: "${displayValue(before)}" → "${displayValue(after)}"`; + }) + .join("; "); +} + +module.exports = { + getActor, + actorLabel, + humanizeFieldName, + displayValue, + summarizeChangedFields, +};