diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e48e77..6954f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## 2.8.2 - 2026-08-16 + +### Changed + +- Standardized update audit events on from/to changes and added readable table diffs for nested JSON, arrays, null values, and empty strings. +- Standardized internal `src/data` imports on the `#src` alias. + ## 2.8.1 - 2026-08-16 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index dc4fc2e..010c931 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.8.1", + "version": "2.8.2", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 7177fc3..5dfe47f 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.8.1", + "version": "2.8.2", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 3e5563e..ade72c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.8.1", + "version": "2.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.8.1", + "version": "2.8.2", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index d510ef0..4333a83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.8.1", + "version": "2.8.2", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/audit-log.js b/src/data/audit-log.js index 1b5a9a2..d892e7e 100644 --- a/src/data/audit-log.js +++ b/src/data/audit-log.js @@ -42,6 +42,21 @@ function normalizeDetails(details) { return JSON.stringify(details); } +function buildAuditChanges(previousValues, nextValues) { + const previous = previousValues && typeof previousValues === 'object' ? previousValues : {}; + const next = nextValues && typeof nextValues === 'object' ? nextValues : {}; + const changes = {}; + const keys = new Set(Object.keys(previous).concat(Object.keys(next))); + + keys.forEach(function (key) { + if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) { + changes[key] = { from: previous[key], to: next[key] }; + } + }); + + return changes; +} + function getRequestMetadata(req) { const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim(); return { @@ -97,6 +112,7 @@ module.exports = { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, getRequestMetadata, + buildAuditChanges, recordAuditEvent, recordRequestAuditEvent }; \ No newline at end of file diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 6362f4c..312cad9 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -255,6 +255,39 @@ .template-preview-card .btn-group { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); +.audit-change-list { + display: grid; + padding: 0.08rem 0.3rem; + border-radius: 0.2rem; + gap: 0.2rem; + min-width: 18rem; +} +.audit-change-row { + display: grid; + grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr); + background: var(--bs-danger-bg-subtle); + gap: 0.35rem; + align-items: baseline; + font-size: 0.78rem; +} + background: var(--bs-success-bg-subtle); +.audit-change-to { + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.audit-change-from { + color: var(--bs-danger-text-emphasis); +} +.audit-change-to { + color: var(--bs-success-text-emphasis); +} +.audit-change-from del, +.audit-change-to ins { + text-decoration-thickness: 2px; +} +.audit-change-arrow { + color: var(--bs-secondary-color); +} gap: 0.5rem; } diff --git a/src/web/routes/admin/account.js b/src/web/routes/admin/account.js index 9e93e8a..f7ef555 100644 --- a/src/web/routes/admin/account.js +++ b/src/web/routes/admin/account.js @@ -1,6 +1,6 @@ // Admin account route registration and profile helpers. -const { fetchAppSettings } = require('../../../data/app-settings'); +const { fetchAppSettings } = require('#src/data/app-settings'); module.exports = function registerAccountRoutes(app, deps) { const pool = deps.pool; diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index e5c9ff9..58318b3 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { loadFontLibrary } = require('#src/web/lib/media/font-library'); const { fetchAppSettings } = require('#src/data/app-settings'); +const { buildAuditChanges } = require('#src/data/audit-log'); const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); module.exports = function registerContentRoutes(app, deps) { @@ -39,6 +40,26 @@ module.exports = function registerContentRoutes(app, deps) { const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024; + function normalizeTemplateRegionsForAudit(regions) { + return (Array.isArray(regions) ? regions : []).map(function (region) { + const animation = typeof region.animation_json === 'string' + ? common.parseJsonSafe(region.animation_json) || {} + : region.animation_json || {}; + return { + region_key: region.region_key, + region_type: region.region_type, + label: region.label, + lock_ratio: region.lock_ratio, + animation_json: animation, + x: Number(region.x), + y: Number(region.y), + width: Number(region.width), + height: Number(region.height), + z_index: Number(region.z_index) + }; + }); + } + async function fetchSlideFormData() { const data = await common.fetchTemplatesData(pool); const rssData = await common.fetchRssFeedsData(pool); @@ -497,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) { } const nextUploadRefs = collectUploadReferencesFromPayload(payload); const actorId = getAuditUserId(req); + const changes = buildAuditChanges({ + title: slide.title, + templateId: Number(slide.template_id), + content: slide.content + }, { + title: payload.title, + templateId: Number(payload.templateId), + content: common.parseJsonSafe(payload.contentJson) || {} + }); await pool.query( 'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?', [payload.title, payload.templateId, payload.contentJson, actorId, slide.id] @@ -512,7 +542,7 @@ module.exports = function registerContentRoutes(app, deps) { screenSlideCounts: screenSlideCounts }); await broadcastDashboardState(); - if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title }); + if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } }); queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) { console.warn('Unable to queue slide thumbnail refresh:', error); }); @@ -655,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) { const nextUploadRefs = collectUploadReferencesFromPayload(payload); const affectedScreens = await fetchScreensByTemplateId(pool, template.id); const actorId = getAuditUserId(req); + const changes = buildAuditChanges({ + name: template.name, + canvasSizeId: Number(template.canvas_size_id), + backgroundImagePath: template.background_image_path, + backgroundColor: template.background_color, + regions: normalizeTemplateRegionsForAudit(template.regions) + }, { + name: payload.name, + canvasSizeId: Number(payload.canvasSizeId), + backgroundImagePath: payload.backgroundImagePath, + backgroundColor: payload.backgroundColor, + regions: normalizeTemplateRegionsForAudit(payload.regions) + }); await pool.query( 'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?', [payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id] @@ -681,7 +724,7 @@ module.exports = function registerContentRoutes(app, deps) { }); } await notifyPlayerScreens(affectedScreens, 'refresh'); - if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name }); + if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name, details: { changes: changes } }); redirectAfterSave(req, res, '/templates/' + template.id + '/edit', { closeUrl: '/templates', newUrl: '/templates/new', @@ -813,8 +856,17 @@ module.exports = function registerContentRoutes(app, deps) { if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) { return res.status(400).send('That canvas size already exists.'); } + const changes = buildAuditChanges({ + name: canvasSize.name, + width: Number(canvasSize.width), + height: Number(canvasSize.height) + }, { + name: payload.name, + width: payload.width, + height: payload.height + }); await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]); - if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { width: payload.width, height: payload.height } }); + if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { changes: changes } }); redirectAfterSave(req, res, '/canvas-sizes', { closeUrl: '/canvas-sizes', newUrl: '/canvas-sizes/new', diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index e997217..7ac9f95 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -1,5 +1,7 @@ // Admin manage routes for screens and commands. +const { buildAuditChanges } = require('#src/data/audit-log'); + module.exports = function registerManageRoutes(app, deps) { const pool = deps.pool; const common = deps.common; @@ -248,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) { const previousPlaylistId = screen.playlist_id; const slug = String(screen.slug || '').trim(); const previousSlug = String(screen.slug || '').trim(); + const changes = buildAuditChanges({ + name: screen.name, + slug: previousSlug, + playlistId: previousPlaylistId === null ? null : Number(previousPlaylistId) + }, { + name: name, + slug: slug, + playlistId: playlistId + }); await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]); if (previousPlaylistId !== playlistId && previousSlug) { await notifyPlayerScreens([previousSlug], 'refresh'); @@ -264,7 +275,7 @@ module.exports = function registerManageRoutes(app, deps) { await forwardPlayerCommand(previousSlug, redirectPayload); } } - if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { slug: slug, playlistId: playlistId } }); + if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { changes: changes } }); redirectAfterSave(req, res, '/screens?edit=' + screen.id, { closeUrl: '/screens', newUrl: '/screens/new', diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 38d6703..030c9a7 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -1,6 +1,7 @@ // Playlist admin routes and playlist-slide management. -const { fetchAppSettings } = require('../../../data/app-settings'); +const { fetchAppSettings } = require('#src/data/app-settings'); +const { buildAuditChanges } = require('#src/data/audit-log'); module.exports = function registerPlaylistRoutes(app, deps) { const pool = deps.pool; @@ -356,7 +357,20 @@ module.exports = function registerPlaylistRoutes(app, deps) { await connection.commit(); await notifyPlayerScreens(saveResult.affectedScreens, 'refresh'); await broadcastDashboardState(); - if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name }); + if (typeof recordRequestAuditEvent === 'function') { + const changes = buildAuditChanges({ + name: playlist.name, + fadeBetweenSlides: Boolean(playlist.fade_between_slides), + skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp), + canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id) + }, { + name: name, + fadeBetweenSlides: Boolean(fadeBetweenSlides), + skipUnavailableRtmp: Boolean(skipUnavailableRtmp), + canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId) + }); + await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } }); + } redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', { closeUrl: '/playlists', newUrl: '/playlists/new', diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index bd17e70..675a02e 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -1,5 +1,6 @@ // Admin RBAC route registration and permission management. const { DEFAULT_ROLE } = require('#src/rbac'); + const { buildAuditChanges } = require('#src/data/audit-log'); module.exports = function registerRbacRoutes(app, deps) { const pool = deps.pool; @@ -413,10 +414,14 @@ return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.')); } + const existingRolePermissionKeys = shouldSyncPermissions + ? await rbacData.fetchRolePermissionKeys(pool, roleId) + : []; + let existingRoleUserIds = []; let availableUsers = []; if (shouldSyncUsers) { availableUsers = await rbacData.fetchUsersWithRoles(pool); - const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId); + existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId); const visibleUserIdSet = new Set(visibleUserIds); normalizedUserIds = existingRoleUserIds.filter(function (userId) { return !visibleUserIdSet.has(userId); @@ -452,6 +457,17 @@ connection.release(); } if (typeof recordRequestAuditEvent === 'function') { + const changes = buildAuditChanges({ + name: role.name, + description: role.description, + permissionKeys: existingRolePermissionKeys, + userIds: existingRoleUserIds + }, { + name: name, + description: description || null, + permissionKeys: normalizedPermissionKeys, + userIds: normalizedUserIds + }); await recordRequestAuditEvent(pool, req, { category: 'roles', eventType: 'role.updated', @@ -459,7 +475,7 @@ targetType: 'role', targetId: roleId, targetLabel: name, - details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers } + details: { changes: changes } }); } res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.')); @@ -494,6 +510,7 @@ })) { return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.')); } + const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId); const connection = await pool.getConnection(); try { @@ -514,7 +531,7 @@ targetType: 'role', targetId: roleId, targetLabel: role.name, - details: { permissionKeys: normalizedPermissionKeys } + details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) } }); } res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.')); @@ -541,6 +558,7 @@ ? [].concat(req.body.user_ids) : []; const normalizedUserIds = normalizeSelectedIds(selectedUserIds); + const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId); const availableUsers = await rbacData.fetchUsersWithRoles(pool); const validUserIds = new Set(availableUsers.map(function (user) { return Number(user.id); @@ -561,7 +579,7 @@ targetType: 'role', targetId: roleId, targetLabel: role.name, - details: { userIds: normalizedUserIds } + details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) } }); } res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.')); diff --git a/src/web/routes/admin/users.js b/src/web/routes/admin/users.js index 3689788..0a9e8fb 100644 --- a/src/web/routes/admin/users.js +++ b/src/web/routes/admin/users.js @@ -1,6 +1,7 @@ // Admin user route registration and user-role management. - const { fetchAppSettings } = require('../../../data/app-settings'); + const { fetchAppSettings } = require('#src/data/app-settings'); + const { buildAuditChanges } = require('#src/data/audit-log'); module.exports = function registerUsersRoutes(app, deps) { const pool = deps.pool; @@ -319,6 +320,8 @@ return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message)); } + const existingUser = await rbacData.fetchUserWithRoles(pool, userId); + const changes = buildAuditChanges({ roleIds: existingUser ? existingUser.roleIds : [] }, { roleIds: roleCheck.roleIds }); await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(pool, req, { @@ -328,7 +331,7 @@ targetType: 'user', targetId: userId, targetLabel: String(userId), - details: { roleIds: roleCheck.roleIds } + details: { changes: changes } }); } res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.')); @@ -404,6 +407,19 @@ return renderValidationError('That username already exists.'); } + const changes = buildAuditChanges({ + name: user.name, + username: user.username, + roleIds: user.roleIds, + accountLocked: Boolean(user.account_locked), + passwordReset: false + }, { + name: name, + username: username, + roleIds: roleCheck.roleIds, + accountLocked: accountLocked, + passwordReset: shouldUpdatePassword + }); await connection.beginTransaction(); const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]); if (!result.affectedRows) { @@ -432,7 +448,7 @@ targetType: 'user', targetId: userId, targetLabel: username, - details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked } + details: { changes: changes } }); if (Boolean(user.account_locked) !== accountLocked) { await recordRequestAuditEvent(pool, req, { diff --git a/src/web/routes/auth/index.js b/src/web/routes/auth/index.js index 956c6d6..45954a1 100644 --- a/src/web/routes/auth/index.js +++ b/src/web/routes/auth/index.js @@ -1,7 +1,7 @@ // Authentication route registration for the web app. const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session'); -const { fetchAppSettings } = require('../../../data/app-settings'); +const { fetchAppSettings } = require('#src/data/app-settings'); module.exports = function registerAuthRoutes(app, deps) { const pool = deps.pool; diff --git a/src/web/routes/data-sources/api-sources/routes.js b/src/web/routes/data-sources/api-sources/routes.js index 67fb516..ad586a9 100644 --- a/src/web/routes/data-sources/api-sources/routes.js +++ b/src/web/routes/data-sources/api-sources/routes.js @@ -5,7 +5,7 @@ const renderApiSourcesPage = require('./list'); const renderApiSourceAddPage = require('./add'); const renderApiSourceEditPage = require('./edit'); const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate'); -const { fetchAppSettings } = require('../../../../data/app-settings'); +const { fetchAppSettings } = require('#src/data/app-settings'); function toIsoTimestamp(value) { if (!value) { diff --git a/src/web/routes/data-sources/rss-feeds/routes.js b/src/web/routes/data-sources/rss-feeds/routes.js index 46cb390..8dbe5e9 100644 --- a/src/web/routes/data-sources/rss-feeds/routes.js +++ b/src/web/routes/data-sources/rss-feeds/routes.js @@ -5,7 +5,7 @@ const renderRssFeedsPage = require('./list'); const renderRssFeedAddPage = require('./add'); const renderRssFeedEditPage = require('./edit'); const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate'); -const { fetchAppSettings } = require('../../../../data/app-settings'); +const { fetchAppSettings } = require('#src/data/app-settings'); async function getDataSourceUsageMaps(pool, common) { const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); diff --git a/src/web/routes/data-sources/timetables/form-view-model.js b/src/web/routes/data-sources/timetables/form-view-model.js index 8edb5f6..e778980 100644 --- a/src/web/routes/data-sources/timetables/form-view-model.js +++ b/src/web/routes/data-sources/timetables/form-view-model.js @@ -1,6 +1,6 @@ // Shared timetable group form view-model builder. -const { normalizeTimeZone } = require('../../../../data/timetables'); +const { normalizeTimeZone } = require('#src/data/timetables'); const COMMON_TIME_ZONES = [ 'UTC', diff --git a/src/web/routes/data-sources/timetables/list.js b/src/web/routes/data-sources/timetables/list.js index de6af52..29492e9 100644 --- a/src/web/routes/data-sources/timetables/list.js +++ b/src/web/routes/data-sources/timetables/list.js @@ -1,7 +1,7 @@ // Timetable group list page renderer. const { renderView } = require('../../../view'); -const { normalizeTimeZone } = require('../../../../data/timetables'); +const { normalizeTimeZone } = require('#src/data/timetables'); function formatDateInTimeZone(value, timeZone) { if (!value) { diff --git a/src/web/routes/settings/audit-log.js b/src/web/routes/settings/audit-log.js index 46cc92c..1c813cc 100644 --- a/src/web/routes/settings/audit-log.js +++ b/src/web/routes/settings/audit-log.js @@ -6,6 +6,10 @@ const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery const PAGE_SIZE = 25; +const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) { + return String(left).localeCompare(String(right)); +}); + function requireAuditLogAccess(setAuthMessageCookie) { return function (req, res, next) { if (!req.currentUser) { @@ -44,11 +48,109 @@ function csvCell(value) { return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"'; } +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function formatAuditValue(value) { + if (typeof value === 'string') { + return value; + } + if (value === undefined) { + return 'undefined'; + } + if (value === null || typeof value !== 'object') { + return String(value); + } + return JSON.stringify(value); +} + +function isPrimitiveArray(value) { + return Array.isArray(value) && value.every(function (item) { + return item === null || typeof item !== 'object'; + }); +} + +function isMissingAuditValue(value) { + return value === null || value === ''; +} + +function collectAuditChangeRows(previousValue, nextValue, path, rows) { + if (isMissingAuditValue(previousValue) && isMissingAuditValue(nextValue)) { + return; + } + if (isMissingAuditValue(previousValue)) { + rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(nextValue) }); + return; + } + if (isMissingAuditValue(nextValue)) { + rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(previousValue), to: '' }); + return; + } + + if (isPrimitiveArray(previousValue) && isPrimitiveArray(nextValue)) { + const previousItems = new Set(previousValue.map(function (item) { return JSON.stringify(item); })); + const nextItems = new Set(nextValue.map(function (item) { return JSON.stringify(item); })); + const removedItems = previousValue.filter(function (item) { return !nextItems.has(JSON.stringify(item)); }); + const addedItems = nextValue.filter(function (item) { return !previousItems.has(JSON.stringify(item)); }); + if (removedItems.length) { + rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(removedItems), to: '' }); + } + if (addedItems.length) { + rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(addedItems) }); + } + return; + } + + if (JSON.stringify(previousValue) === JSON.stringify(nextValue)) { + return; + } + + if (isRecord(previousValue) && isRecord(nextValue)) { + const keys = new Set(Object.keys(previousValue).concat(Object.keys(nextValue))); + keys.forEach(function (key) { + collectAuditChangeRows(previousValue[key], nextValue[key], path ? path + '.' + key : key, rows); + }); + return; + } + + if (Array.isArray(previousValue) && Array.isArray(nextValue) && previousValue.length === nextValue.length && previousValue.some(isRecord)) { + for (let index = 0; index < previousValue.length; index += 1) { + collectAuditChangeRows(previousValue[index], nextValue[index], path + '[' + index + ']', rows); + } + return; + } + + rows.push({ + path: path, + direction: 'changed', + from: formatAuditValue(previousValue), + to: formatAuditValue(nextValue) + }); +} + +function buildAuditDetailView(details) { + if (!isRecord(details) || !isRecord(details.changes)) { + return { hasChanges: false, changeRows: [] }; + } + + const rows = []; + Object.keys(details.changes).forEach(function (key) { + const change = details.changes[key]; + if (isRecord(change) && Object.prototype.hasOwnProperty.call(change, 'from') && Object.prototype.hasOwnProperty.call(change, 'to')) { + collectAuditChangeRows(change.from, change.to, key, rows); + } + }); + return { hasChanges: rows.length > 0, changeRows: rows }; +} + function mapAuditRow(row, formatDashboardDate) { let details = ''; + let parsedDetails = null; if (row.details_json) { try { - details = JSON.stringify(typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json); + parsedDetails = typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json; + details = JSON.stringify(parsedDetails); } catch (_error) { details = String(row.details_json); } @@ -60,7 +162,8 @@ function mapAuditRow(row, formatDashboardDate) { actorLabel: row.actor_name || row.actor_username || 'System', eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '), targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''), - detailsDisplay: details + detailsDisplay: details, + auditDetailView: buildAuditDetailView(parsedDetails) }); } @@ -136,7 +239,7 @@ module.exports = function registerAuditLogRoutes(app, deps) { active: 'audit-log', currentUser: req.currentUser, events: events, - categories: AUDIT_CATEGORY_KEYS, + categories: SORTED_AUDIT_CATEGORY_KEYS, eventTypes: eventTypes, selectedCategory: category, selectedEventType: eventType, diff --git a/src/web/routes/signage/playlists/routes.js b/src/web/routes/signage/playlists/routes.js index 2c4dabc..844085e 100644 --- a/src/web/routes/signage/playlists/routes.js +++ b/src/web/routes/signage/playlists/routes.js @@ -8,7 +8,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) { const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate'); - const { fetchAppSettings } = require('../../../../data/app-settings'); + const { fetchAppSettings } = require('#src/data/app-settings'); const LIST_PAGE_SIZE = 25; diff --git a/src/web/views/settings/audit-log/index.hbs b/src/web/views/settings/audit-log/index.hbs index bac18f4..7449ffe 100644 --- a/src/web/views/settings/audit-log/index.hbs +++ b/src/web/views/settings/audit-log/index.hbs @@ -44,7 +44,22 @@
{{path}}
+ From: {{from}}
+ {{#if (eq direction "changed")}}{{else}}{{/if}}
+ To: {{to}}
+