Release v2.2.0
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
// Account password page renderer and return URL normalization.
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
function normalizeReturnUrl(value) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
// Admin account route registration and profile helpers.
|
||||
|
||||
module.exports = function registerAccountRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
// Admin client command routes for connected screens.
|
||||
|
||||
module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// Admin content routes for media uploads and related files.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadFontLibrary } = require('../../lib/media/font-library');
|
||||
|
||||
module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
module.exports = function registerContentRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const upload = deps.upload;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
const fetchScreensBySlideId = deps.fetchScreensBySlideId;
|
||||
const fetchScreensByTemplateId = deps.fetchScreensByTemplateId;
|
||||
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
||||
@@ -25,12 +29,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const hasAnyPermission = deps.hasAnyPermission;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
|
||||
throw new Error('registerContentRoutes requires the content route dependencies.');
|
||||
}
|
||||
|
||||
async function removeMediaFile(uploadPath) {
|
||||
@@ -53,7 +57,11 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
|
||||
function requireSlideUploadPermission(req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['slides.create', 'slides.update'])) {
|
||||
@@ -279,7 +287,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
|
||||
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -292,6 +300,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
@@ -308,7 +317,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
|
||||
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -482,6 +491,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildTemplatePayload(pool, req, null);
|
||||
if (!payload.regions.length) {
|
||||
return res.status(400).send('At least 1 region needs to be added.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name)) {
|
||||
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
@@ -493,8 +505,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -520,6 +532,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
|
||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
@@ -554,8 +567,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -633,7 +646,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const payload = common.buildCanvasSizePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
@@ -657,6 +670,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||
canvasSize.inUse = Number(rows[0] && rows[0].ref_count) > 0;
|
||||
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -670,7 +685,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name, canvasSize.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
// Admin data-source route registration for API and RSS sources.
|
||||
|
||||
module.exports = function registerDataSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
const { hasAnyPermission } = require('#src/rbac');
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
|
||||
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
|
||||
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
@@ -26,150 +27,112 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, apiUrl, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await loadApiSourceResponse(apiUrl);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
function slideUsesDataSourceField(content, fieldName, targetId) {
|
||||
const normalizedTargetId = Number(targetId);
|
||||
if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
updatedItems = await loadRssFeedItems(feedUrl, itemLimit);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_rss_feeds SET modified_by = ? WHERE id = ?',
|
||||
[actorId, rssFeedId]
|
||||
);
|
||||
if (replaceRssFeedItems) {
|
||||
await replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.commit();
|
||||
|
||||
if (pullError) {
|
||||
console.error('[admin-data-sources] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
if (Array.isArray(content)) {
|
||||
return content.some(function (item) {
|
||||
return slideUsesDataSourceField(item, fieldName, normalizedTargetId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
|
||||
throw new Error('registerAdminDataSourceRoutes requires the data source route dependencies.');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: require('../../lib/background-task-queue').normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
return Object.keys(content).some(function (key) {
|
||||
return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId);
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
async function getDataSourceDeleteBlockMessage(fieldName, dataSourceId, label) {
|
||||
const isInUse = await getDataSourceInUse(fieldName, dataSourceId);
|
||||
if (isInUse) {
|
||||
return 'This ' + label + ' is still used by one or more slides.';
|
||||
}
|
||||
|
||||
const task = backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getDataSourceInUse(fieldName, dataSourceId) {
|
||||
const [slides] = await pool.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (slideUsesDataSourceField(content, fieldName, dataSourceId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getDataSourceUsageMaps() {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const apiSourceIds = new Set();
|
||||
const rssFeedIds = new Set();
|
||||
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
|
||||
const sourceId = Number(value.source_id);
|
||||
if (Number.isFinite(sourceId)) {
|
||||
apiSourceIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
|
||||
const feedId = Number(value.feed_id);
|
||||
if (Number.isFinite(feedId)) {
|
||||
rssFeedIds.add(feedId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
apiSourceIds: apiSourceIds,
|
||||
rssFeedIds: rssFeedIds
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRssFeedItems(feedUrl, itemLimit) {
|
||||
if (typeof fetchRssFeedItems === 'function') {
|
||||
return fetchRssFeedItems(feedUrl, itemLimit);
|
||||
}
|
||||
|
||||
return [];
|
||||
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
|
||||
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
|
||||
}
|
||||
|
||||
async function loadApiSourceResponse(apiUrl) {
|
||||
if (typeof fetchApiSourceResponse === 'function') {
|
||||
return fetchApiSourceResponse(apiUrl);
|
||||
}
|
||||
const dataSourceTasks = createDataSourceTaskService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
|
||||
return {
|
||||
responseJson: null,
|
||||
responseStatus: null,
|
||||
responseContentType: null
|
||||
};
|
||||
}
|
||||
|
||||
function sendRefreshTaskState(req, res, sourceType, sourceId) {
|
||||
async function sendRefreshTaskState(req, res, sourceType, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = getTaskStatusById(taskId);
|
||||
const task = await dataSourceTasks.getTaskStatusById(taskId);
|
||||
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
@@ -180,7 +143,11 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
|
||||
app.get('/data-sources', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
|
||||
@@ -203,13 +170,22 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageMaps = await getDataSourceUsageMaps();
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : ''
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||
inUse: usageMaps.apiSourceIds.has(Number(apiSource.id))
|
||||
});
|
||||
});
|
||||
res.send(pages.renderApiSourcesPage({
|
||||
@@ -235,12 +211,12 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_api_sources (name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(result.insertId, payload.apiUrl, actorId);
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
@@ -280,8 +256,17 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const inUse = await getDataSourceInUse('source_id', apiSource.id);
|
||||
|
||||
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
@@ -289,7 +274,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || ''
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
inUse: inUse
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
@@ -299,8 +285,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id));
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
@@ -312,18 +298,18 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name, apiSource.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(apiSource.id, payload.apiUrl, actorId);
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
@@ -363,12 +349,17 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const blockMessage = await getDataSourceDeleteBlockMessage('source_id', apiSource.id, 'API source');
|
||||
if (blockMessage) {
|
||||
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('api-source', apiSource.id);
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
@@ -388,8 +379,14 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageMaps = await getDataSourceUsageMaps();
|
||||
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
inUse: usageMaps.rssFeedIds.has(Number(rssFeed.id))
|
||||
});
|
||||
});
|
||||
res.send(pages.renderRssFeedsPage({
|
||||
rssFeeds: data.rssFeeds || [],
|
||||
rssFeeds: rssFeeds,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
@@ -405,7 +402,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -415,8 +412,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + result.insertId,
|
||||
@@ -456,6 +453,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const inUse = await getDataSourceInUse('feed_id', rssFeed.id);
|
||||
|
||||
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||
: [];
|
||||
@@ -464,7 +463,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
feedUrl: rssFeed.feed_url,
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit
|
||||
itemLimit: rssFeed.item_limit,
|
||||
inUse: inUse
|
||||
}), {
|
||||
pulledItems: pulledItems,
|
||||
pullError: ''
|
||||
@@ -474,8 +474,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id));
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
@@ -487,7 +487,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name, rssFeed.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -497,8 +497,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
@@ -538,12 +538,17 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const blockMessage = await getDataSourceDeleteBlockMessage('feed_id', rssFeed.id, 'RSS feed');
|
||||
if (blockMessage) {
|
||||
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
// Admin manage routes for screens, playlists, slides, and templates.
|
||||
|
||||
module.exports = function registerManageRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
@@ -559,7 +561,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, player_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, slug, playlistId, '1', actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
@@ -588,14 +590,17 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
const previousSlug = String(screen.slug || '').trim();
|
||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, '1', getAuditUserId(req), screen.id]);
|
||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||
}
|
||||
if (previousSlug && previousSlug !== slug) {
|
||||
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
||||
? await common.fetchScreenPlayerRecord(pool, previousSlug)
|
||||
: null;
|
||||
await forwardPlayerCommand(previousSlug, {
|
||||
command: 'redirect',
|
||||
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
|
||||
url: (playerRecord && playerRecord.public_base_url ? String(playerRecord.public_base_url).replace(/\/$/, '') : PLAYER_PUBLIC_BASE_URL) + `/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module.exports = function registerAdminPagesRoutes(app, deps) {
|
||||
// Admin page route composition for the signage sections.
|
||||
|
||||
module.exports = function registerPagesRoutes(app, deps) {
|
||||
require('../signage/dashboard/routes')(app, deps);
|
||||
require('../signage/clients/routes')(app, deps);
|
||||
require('../signage/screens/routes')(app, deps);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
// Admin RBAC route registration and permission management.
|
||||
|
||||
module.exports = function registerRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
@@ -10,7 +12,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
@@ -259,6 +261,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
excludeUserId: currentUserId
|
||||
});
|
||||
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
|
||||
viewModel.role.inUse = String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
|
||||
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
|
||||
} catch (error) {
|
||||
@@ -295,7 +298,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) {
|
||||
if (await common.fetchDuplicateName(pool, 'a_roles', name, roleId)) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
// Admin user route registration and user-role management.
|
||||
|
||||
module.exports = function registerUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
@@ -10,7 +12,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
return rbacData.fetchRoles(pool);
|
||||
@@ -101,12 +103,16 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
isCurrentUser: false,
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned',
|
||||
inUse: !canDelete
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Authentication route registration for the web app.
|
||||
|
||||
module.exports = function registerAuthRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
@@ -5,6 +7,8 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
const clearSessionCookie = deps.clearSessionCookie;
|
||||
const parseCookies = deps.parseCookies;
|
||||
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
const hashSessionToken = deps.hashSessionToken;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
@@ -17,7 +21,11 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
if (req.currentUser) {
|
||||
return res.redirect('/dashboard');
|
||||
}
|
||||
res.send(pages.renderLoginPage(req.query.message ? String(req.query.message) : ''));
|
||||
|
||||
const message = typeof consumeAuthMessageCookie === 'function'
|
||||
? consumeAuthMessageCookie(req, res)
|
||||
: (req.query.message ? String(req.query.message) : '');
|
||||
res.send(pages.renderLoginPage(message));
|
||||
});
|
||||
|
||||
app.post('/login', async function (req, res, next) {
|
||||
@@ -31,7 +39,12 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
const user = rows[0] || null;
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Invalid username or password.'));
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Invalid username or password.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
return res.status(401).send(pages.renderLoginPage('Invalid username or password.'));
|
||||
}
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
@@ -50,7 +63,12 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||
}
|
||||
clearSessionCookie(res);
|
||||
res.redirect('/login?message=' + encodeURIComponent('You have been signed out.'));
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'You have been signed out.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
res.redirect('/login');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Sign-in page renderer for the authentication flow.
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderLoginPage(message) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared route helpers for web view rendering and player URL formatting.
|
||||
|
||||
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
function escapeHtml(value) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// API source add page renderer and form defaults.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultApiSource() {
|
||||
@@ -5,6 +7,13 @@ function buildDefaultApiSource() {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
authMethod: 'none',
|
||||
authUsername: '',
|
||||
authPassword: '',
|
||||
authBearerToken: '',
|
||||
authHeaderName: 'X-API-Key',
|
||||
authHeaderValue: '',
|
||||
itemsPath: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
lastPulledAt: null,
|
||||
@@ -25,6 +34,7 @@ module.exports = function renderApiSourceFormPage(apiSource, mode, message, curr
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewApiSource.lastPullError || ''
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// API source edit page renderer that reuses the add form.
|
||||
|
||||
const renderApiSourceFormPage = require('./add');
|
||||
|
||||
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
|
||||
@@ -8,6 +10,13 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
|
||||
|
||||
return renderApiSourceFormPage(Object.assign({}, apiSource, {
|
||||
lastResponseJson: viewData.lastResponseJson,
|
||||
lastPullError: viewData.lastPullError
|
||||
lastPullError: viewData.lastPullError,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || ''
|
||||
}), 'edit', message, currentUser);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// API source list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Data-source route entry point that re-exports the admin handlers.
|
||||
|
||||
module.exports = require('../admin/data-sources');
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// RSS feed add page renderer and form defaults.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultRssFeed() {
|
||||
@@ -21,6 +23,7 @@ module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentU
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeed: viewRssFeed,
|
||||
inUse: Boolean(viewRssFeed.inUse),
|
||||
pulledItems: viewRssFeed.pulledItems || [],
|
||||
pullError: viewRssFeed.pullError || ''
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// RSS feed edit page renderer that reuses the add form.
|
||||
|
||||
const renderRssFeedFormPage = require('./add');
|
||||
|
||||
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// RSS feed list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Error page route helpers and error copy selection.
|
||||
|
||||
const { renderView } = require('../view');
|
||||
|
||||
function getErrorCopy(statusCode, message) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Aggregate the primary web route registrations.
|
||||
|
||||
module.exports = {
|
||||
renderLoginPage: require('./auth/login'),
|
||||
renderAccountPage: require('./account/password'),
|
||||
@@ -19,6 +21,9 @@ module.exports = {
|
||||
renderScreensPage: require('./screens/list'),
|
||||
renderScreenFormPage: require('./screens/add'),
|
||||
renderScreenEditPage: require('./screens/edit'),
|
||||
renderAnnouncementsPage: require('./announcements/list'),
|
||||
renderAnnouncementFormPage: require('./announcements/add'),
|
||||
renderAnnouncementEditPage: require('./announcements/edit'),
|
||||
renderSlidesPage: require('./slides/list'),
|
||||
renderSlideFormPage: require('./slides/form'),
|
||||
renderTemplatesPage: require('./templates/list'),
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Compose the primary web route registrations.
|
||||
|
||||
const registerAuthRoutes = require('./auth');
|
||||
const registerPagesRoutes = require('./admin/pages');
|
||||
const registerAccountRoutes = require('./admin/account');
|
||||
const registerUsersRoutes = require('./admin/users');
|
||||
const registerManageRoutes = require('./admin/manage');
|
||||
const registerScreenCommandRoutes = require('./admin/client-commands');
|
||||
const registerAnnouncementRoutes = require('./signage/announcements/routes');
|
||||
const registerContentRoutes = require('./admin/content');
|
||||
const registerDataSourceRoutes = require('./admin/data-sources');
|
||||
const registerSettingsRoutes = require('./settings/background-tasks');
|
||||
const registerFontRoutes = require('./settings/fonts');
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function registerRoutes(app, deps) {
|
||||
// Group routes by the dependency bundle they need.
|
||||
registerAuthAndAccountRoutes(app, deps);
|
||||
registerSignageRoutes(app, deps);
|
||||
registerSettingsAndContentRoutes(app, deps);
|
||||
}
|
||||
|
||||
function registerAuthAndAccountRoutes(app, deps) {
|
||||
// Auth, profile, and user management routes share the same session helpers.
|
||||
registerAuthRoutes(app, {
|
||||
pool: deps.pool,
|
||||
pages: deps.pages,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie,
|
||||
clearSessionCookie: deps.clearSessionCookie,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
|
||||
parseCookies: deps.parseCookies,
|
||||
hashSessionToken: deps.hashSessionToken,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
sessionCookieName: deps.sessionCookieName
|
||||
});
|
||||
|
||||
registerPagesRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
requirePermission: deps.requirePermission,
|
||||
buildDashboardState: deps.buildDashboardState,
|
||||
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections
|
||||
});
|
||||
|
||||
registerAccountRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
hashPassword: deps.hashPassword,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie
|
||||
});
|
||||
|
||||
registerUsersRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
hashPassword: deps.hashPassword,
|
||||
readArrayField: deps.readArrayField,
|
||||
rbacData: deps.rbacData,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
}
|
||||
|
||||
function registerSignageRoutes(app, deps) {
|
||||
// Screen, playlist, and command routes share the player-facing wiring.
|
||||
registerManageRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
fetchOrderedPlaylistSlides: deps.fetchOrderedPlaylistSlides,
|
||||
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
||||
fetchPlaylistCanvasSignature: deps.fetchPlaylistCanvasSignature,
|
||||
getCanvasSignature: deps.getCanvasSignature,
|
||||
normalizeScheduleMode: deps.normalizeScheduleMode,
|
||||
parseDateTimeLocal: deps.parseDateTimeLocal,
|
||||
parseTimeLocal: deps.parseTimeLocal,
|
||||
readArrayField: deps.readArrayField,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
notifyPlayerScreens: deps.notifyPlayerScreens,
|
||||
broadcastDashboardState: deps.broadcastDashboardState,
|
||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
|
||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||
playerPublicBaseUrl: deps.playerPublicBaseUrl,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerAnnouncementRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
forwardAnnouncementRefresh: deps.playerActionService.forwardAnnouncementRefresh,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: deps.pool,
|
||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
isClientNameAvailable: deps.isClientNameAvailable,
|
||||
withClientNameReservation: deps.withClientNameReservation,
|
||||
broadcastDashboardState: deps.broadcastDashboardState,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
}
|
||||
|
||||
function registerSettingsAndContentRoutes(app, deps) {
|
||||
// Content, data-source, RBAC, and settings routes share the admin-side services.
|
||||
const registerRbacRoutes = require('./admin/rbac');
|
||||
|
||||
registerRbacRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
rbacData: deps.rbacData,
|
||||
readArrayField: deps.readArrayField,
|
||||
permissions: PERMISSIONS,
|
||||
normalizePermissionKeys: normalizePermissionKeys,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerContentRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
upload: deps.upload,
|
||||
uploadDir: deps.uploadDir,
|
||||
fetchScreensBySlideId: deps.fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: deps.fetchScreensByTemplateId,
|
||||
collectUploadReferencesFromSlide: deps.collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: deps.collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: deps.collectUploadReferencesFromPayload,
|
||||
removeUnusedUploadFiles: deps.removeUnusedUploadFiles,
|
||||
syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
notifyPlayerScreens: deps.notifyPlayerScreens,
|
||||
broadcastDashboardState: deps.broadcastDashboardState,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
hasAnyPermission: deps.hasAnyPermission,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
getSlideDeleteBlockMessage: deps.playerActionService.getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: deps.playerActionService.getTemplateDeleteBlockMessage,
|
||||
getCanvasSizeDeleteBlockMessage: deps.playerActionService.getCanvasSizeDeleteBlockMessage,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerDataSourceRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
dataSourceTasks: deps.dataSourceTasks,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerSettingsRoutes(app, {
|
||||
pages: deps.pages,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie
|
||||
});
|
||||
|
||||
registerFontRoutes(app, {
|
||||
pool: deps.pool,
|
||||
pages: deps.pages,
|
||||
upload: deps.upload,
|
||||
uploadDir: deps.uploadDir,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerRoutes };
|
||||
@@ -1,9 +1,11 @@
|
||||
// Background tasks settings page renderer.
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const { buildPagination, buildQueryString } = require('../../lib/pagination');
|
||||
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
|
||||
|
||||
const TASKS_PER_PAGE = 10;
|
||||
const TASKS_PER_PAGE = 25;
|
||||
const QUEUE_PAGE_URL = '/settings/tasks-background';
|
||||
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
|
||||
|
||||
@@ -91,7 +93,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
const tasks = (data && data.tasks) || [];
|
||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
|
||||
const sort = String(data && data.sort || '').trim() || 'started';
|
||||
const sort = String(data && data.sort || '').trim() || 'queued';
|
||||
const direction = normalizeSortDirection(data && data.direction || 'desc');
|
||||
const queryState = {
|
||||
page: parsePageNumber(data && data.page),
|
||||
@@ -188,6 +190,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
return renderView('settings/tasks-background/index', {
|
||||
title: 'Background tasks',
|
||||
active: 'background-tasks',
|
||||
bodyClass: 'background-tasks-page',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/settings/background-tasks.js'],
|
||||
@@ -211,7 +214,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
|
||||
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
|
||||
const search = String(data && data.search || '').trim();
|
||||
const sort = String(data && data.sort || '').trim();
|
||||
const sort = String(data && data.sort || '').trim() || 'task';
|
||||
const direction = normalizeSortDirection(data && data.direction);
|
||||
const filteredRecurringTasks = recurringTasks.filter(function (task) {
|
||||
return taskMatchesSearch(search, [
|
||||
@@ -249,7 +252,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
||||
return String(value || '').trim();
|
||||
}).join(' ');
|
||||
}
|
||||
return task && task.nextRunAt;
|
||||
return task && task.title;
|
||||
}, direction);
|
||||
|
||||
return renderView('settings/tasks-scheduled/index', {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
// Settings route registration for the background tasks page.
|
||||
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
|
||||
function requireSettingsAccess() {
|
||||
function redirectToLogin(res, setAuthMessageCookie) {
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
function requireSettingsAccess(setAuthMessageCookie) {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
return redirectToLogin(res, setAuthMessageCookie);
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow'])) {
|
||||
@@ -17,10 +28,10 @@ function requireSettingsAccess() {
|
||||
};
|
||||
}
|
||||
|
||||
function requireScheduledSettingsAccess() {
|
||||
function requireScheduledSettingsAccess(setAuthMessageCookie) {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
return redirectToLogin(res, setAuthMessageCookie);
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['scheduled-tasks.read', 'scheduled-tasks.allow'])) {
|
||||
@@ -34,32 +45,41 @@ function requireScheduledSettingsAccess() {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
module.exports = function registerSettingsRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
|
||||
if (!pages || !backgroundTaskQueue) {
|
||||
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
|
||||
throw new Error('registerSettingsRoutes requires pages and backgroundTaskQueue.');
|
||||
}
|
||||
|
||||
app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
|
||||
const tasks = backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = backgroundTaskQueue.getSummary();
|
||||
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
app.get('/settings/tasks-background', requireSettingsAccess(setAuthMessageCookie), async function (req, res, next) {
|
||||
try {
|
||||
const tasks = await backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = await backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = await backgroundTaskQueue.getSummary();
|
||||
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
|
||||
const tasks = backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = backgroundTaskQueue.getSummary();
|
||||
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(setAuthMessageCookie), async function (req, res, next) {
|
||||
try {
|
||||
const tasks = await backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = await backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = await backgroundTaskQueue.getSummary();
|
||||
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
function requireSettingsManageAccess() {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
return redirectToLogin(res, setAuthMessageCookie);
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['background-tasks.allow'])) {
|
||||
@@ -75,7 +95,7 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
|
||||
function requireRecurringTaskAccess(req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
return redirectToLogin(res, setAuthMessageCookie);
|
||||
}
|
||||
|
||||
if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) {
|
||||
@@ -88,23 +108,39 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
return next();
|
||||
}
|
||||
|
||||
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), function (req, res) {
|
||||
const removedCount = backgroundTaskQueue.clearFinishedTasks();
|
||||
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
|
||||
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), async function (req, res, next) {
|
||||
try {
|
||||
const removedCount = await backgroundTaskQueue.clearFinishedTasks();
|
||||
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), function (req, res) {
|
||||
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
|
||||
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
|
||||
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), async function (req, res, next) {
|
||||
try {
|
||||
const canceled = await backgroundTaskQueue.cancelTask(Number(req.params.id));
|
||||
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) {
|
||||
const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
||||
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), async function (req, res, next) {
|
||||
try {
|
||||
const retriedTask = await backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, function (req, res) {
|
||||
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
||||
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
||||
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, async function (req, res, next) {
|
||||
try {
|
||||
const ran = await backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
||||
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// Settings route registration for managed fonts.
|
||||
|
||||
const fs = require('fs');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const fontLibrary = require('../../lib/media/font-library');
|
||||
|
||||
function normalizeFontFamilyName(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function fetchUsedFontFamilies(pool, fonts) {
|
||||
const usedFamilies = new Set();
|
||||
|
||||
for (let i = 0; i < (Array.isArray(fonts) ? fonts.length : 0); i += 1) {
|
||||
const family = normalizeFontFamilyName(fonts[i] && (fonts[i].family || fonts[i].name));
|
||||
if (!family) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
|
||||
[family]
|
||||
);
|
||||
if (Number(rows && rows[0] && rows[0].match_count) > 0) {
|
||||
usedFamilies.add(family);
|
||||
}
|
||||
}
|
||||
|
||||
return usedFamilies;
|
||||
}
|
||||
|
||||
async function isFontUsed(pool, family) {
|
||||
const normalizedFamily = normalizeFontFamilyName(family);
|
||||
if (!normalizedFamily) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
|
||||
[normalizedFamily]
|
||||
);
|
||||
|
||||
return Number(rows && rows[0] && rows[0].match_count) > 0;
|
||||
}
|
||||
|
||||
function redirectToLogin(res, setAuthMessageCookie) {
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
function requireFontsAccess(setAuthMessageCookie) {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return redirectToLogin(res, setAuthMessageCookie);
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['fonts.read', 'fonts.create', 'fonts.delete'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerFontRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const upload = deps.upload;
|
||||
const uploadDir = deps.uploadDir;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
|
||||
if (!pool || !pages || !upload || !uploadDir) {
|
||||
throw new Error('registerFontRoutes requires pool, pages, upload, and uploadDir.');
|
||||
}
|
||||
|
||||
app.get('/settings/fonts', requireFontsAccess(setAuthMessageCookie), async function (req, res, next) {
|
||||
try {
|
||||
const library = fontLibrary.loadFontLibrary(uploadDir);
|
||||
const usedFamilies = await fetchUsedFontFamilies(pool, library.fonts);
|
||||
const fonts = library.fonts.map(function (font) {
|
||||
const family = normalizeFontFamilyName(font.family || font.name);
|
||||
return Object.assign({}, font, {
|
||||
inUse: usedFamilies.has(family)
|
||||
});
|
||||
});
|
||||
res.send(pages.renderFontsPage({
|
||||
fonts: fonts,
|
||||
stylesheetHref: library.stylesheetHref
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/fonts', requireFontsAccess(setAuthMessageCookie), upload.single('font_file'), async function (req, res, next) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).send('No font file was uploaded.');
|
||||
}
|
||||
|
||||
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim());
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||
await backgroundTaskQueue.enqueueTask({
|
||||
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`,
|
||||
title: 'Font sync',
|
||||
category: 'fonts',
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
uploadDir: uploadDir,
|
||||
operations: fontLibrary.collectFontLibrarySyncOperations(uploadDir)
|
||||
},
|
||||
persist: true
|
||||
});
|
||||
}
|
||||
|
||||
res.redirect('/settings/fonts?message=' + encodeURIComponent('Font uploaded.'));
|
||||
} catch (error) {
|
||||
if (req.file && req.file.path) {
|
||||
try {
|
||||
await fs.promises.unlink(req.file.path);
|
||||
} catch (_cleanupError) {}
|
||||
}
|
||||
|
||||
if (error && /already exists/i.test(String(error.message || ''))) {
|
||||
const library = fontLibrary.loadFontLibrary(uploadDir);
|
||||
const usedFamilies = await fetchUsedFontFamilies(pool, library.fonts);
|
||||
const fonts = library.fonts.map(function (font) {
|
||||
const family = normalizeFontFamilyName(font.family || font.name);
|
||||
return Object.assign({}, font, {
|
||||
inUse: usedFamilies.has(family)
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(400).send(pages.renderFontsPage({
|
||||
fonts: fonts,
|
||||
stylesheetHref: library.stylesheetHref
|
||||
}, String(error.message || 'Font already exists.'), req.currentUser));
|
||||
}
|
||||
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/fonts/:id/toggle', requireFontsAccess(setAuthMessageCookie), async function (req, res, next) {
|
||||
try {
|
||||
const enabled = String(req.body && req.body.enabled || '').trim() !== '0';
|
||||
const updated = await fontLibrary.setFontEnabled(uploadDir, String(req.params.id || '').trim(), enabled);
|
||||
if (!updated) {
|
||||
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
|
||||
}
|
||||
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||
await backgroundTaskQueue.enqueueTask({
|
||||
key: `font-sync:toggle:${updated && updated.id ? updated.id : Date.now()}`,
|
||||
title: 'Font sync',
|
||||
category: 'fonts',
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
uploadDir: uploadDir,
|
||||
operations: fontLibrary.collectFontLibrarySyncOperations(uploadDir)
|
||||
},
|
||||
persist: true
|
||||
});
|
||||
}
|
||||
|
||||
res.redirect('/settings/fonts?message=' + encodeURIComponent(enabled ? 'Font enabled.' : 'Font disabled.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/fonts/:id/delete', requireFontsAccess(setAuthMessageCookie), async function (req, res, next) {
|
||||
try {
|
||||
const fontId = String(req.params.id || '').trim();
|
||||
const currentFont = fontLibrary.loadFontLibrary(uploadDir).fonts.find(function (font) {
|
||||
return String(font.id || font.fileName || '').trim() === fontId || String(font.fileName || '').trim() === fontId;
|
||||
});
|
||||
const family = currentFont ? String(currentFont.family || currentFont.name || '').trim() : '';
|
||||
if (family && await isFontUsed(pool, family)) {
|
||||
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font is still used by one or more slides.'));
|
||||
}
|
||||
|
||||
const removed = await fontLibrary.deleteFontFile(uploadDir, fontId);
|
||||
if (!removed) {
|
||||
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
|
||||
}
|
||||
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||
await backgroundTaskQueue.enqueueTask({
|
||||
key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`,
|
||||
title: 'Font sync',
|
||||
category: 'fonts',
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
uploadDir: uploadDir,
|
||||
operations: [
|
||||
removed.fileName ? { type: 'delete', uploadPath: '/media/fonts/' + removed.fileName } : null
|
||||
].concat(fontLibrary.collectFontLibrarySyncOperations(uploadDir)).filter(Boolean)
|
||||
},
|
||||
persist: true
|
||||
});
|
||||
}
|
||||
|
||||
res.redirect('/settings/fonts?message=' + encodeURIComponent('Font removed.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
// Managed fonts settings page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderFontsPage(data, message, currentUser) {
|
||||
return renderView('settings/fonts/list', {
|
||||
title: 'Fonts',
|
||||
active: 'fonts',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
fonts: data.fonts || [],
|
||||
stylesheetHref: data.stylesheetHref || ''
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// RBAC role add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// RBAC role edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
|
||||
@@ -7,6 +9,7 @@ module.exports = function renderRbacEditPage(role, message, currentUser, permiss
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
role: role,
|
||||
inUse: Boolean(role && role.inUse),
|
||||
permissionGroups: permissionGroups || [],
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// RBAC role-and-permission list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderRbacPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// User add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// User edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
|
||||
@@ -7,6 +9,7 @@ module.exports = function renderUsersEditPage(user, message, currentUser, roles)
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user,
|
||||
inUse: Boolean(user && user.inUse),
|
||||
roles: roles || []
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// User list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderUsersPage(data, message, currentUser) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Announcement add page renderer and form defaults.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
|
||||
|
||||
function buildDefaultAnnouncement() {
|
||||
return {
|
||||
id: null,
|
||||
message: '',
|
||||
short_label: '',
|
||||
announcement_type: 'lower-third',
|
||||
color_key: 'primary',
|
||||
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
|
||||
durationMode: 'duration',
|
||||
duration_seconds: null,
|
||||
durationValue: 5,
|
||||
durationUnit: 'minutes',
|
||||
durationLabel: 'Until disabled',
|
||||
screen_ids: []
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderAnnouncementFormPage(announcement, mode, message, currentUser, options) {
|
||||
const isEdit = mode === 'edit';
|
||||
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
|
||||
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
|
||||
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
|
||||
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
|
||||
|
||||
return renderView(isEdit ? 'signage/announcements/edit' : 'signage/announcements/add', {
|
||||
title: isEdit ? 'Edit announcement' : 'Add announcement',
|
||||
active: 'announcements',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
announcement: Object.assign({}, buildDefaultAnnouncement(), announcement || {}),
|
||||
announcementTypes: announcementTypes,
|
||||
announcementColors: announcementColors,
|
||||
announcementIcons: announcementIcons,
|
||||
announcementScreens: announcementScreens
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// Announcement edit page renderer that reuses the add form.
|
||||
|
||||
const renderAnnouncementFormPage = require('./add');
|
||||
|
||||
module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) {
|
||||
return renderAnnouncementFormPage(announcement, 'edit', message, currentUser, options);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
// Announcement list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { ANNOUNCEMENT_ICON_LABELS, DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
|
||||
|
||||
function formatDurationLabel(seconds) {
|
||||
const totalSeconds = Math.max(1, Number(seconds) || 0);
|
||||
if (totalSeconds % 60 === 0) {
|
||||
const minutes = totalSeconds / 60;
|
||||
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
||||
}
|
||||
return totalSeconds === 1 ? '1 second' : `${totalSeconds} seconds`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(announcement) {
|
||||
if (announcement.expires_at && new Date(announcement.expires_at).getTime() <= Date.now()) {
|
||||
return 'Expired';
|
||||
}
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
function formatActionButton(announcement) {
|
||||
const isActive = formatStatusLabel(announcement) === 'Active';
|
||||
return {
|
||||
label: isActive ? 'Stop' : 'Play',
|
||||
icon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
className: isActive ? 'btn-outline-warning' : 'btn-outline-success',
|
||||
confirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
actionPath: isActive ? `/announcements/${announcement.id}/stop` : `/announcements/${announcement.id}/play`
|
||||
};
|
||||
}
|
||||
|
||||
function formatIconLabel(iconKey) {
|
||||
const normalized = String(iconKey || '').trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON;
|
||||
return ANNOUNCEMENT_ICON_LABELS[normalized] || normalized;
|
||||
}
|
||||
|
||||
function formatExpiresLabel(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatScreenTargetsLabel(announcement) {
|
||||
const targetCount = Math.max(0, Number(announcement && announcement.screen_target_count) || 0);
|
||||
const totalScreenCount = Math.max(0, Number(announcement && announcement.total_screen_count) || 0);
|
||||
const screenNames = String(announcement && announcement.screen_targets_label || '').trim();
|
||||
|
||||
if (!targetCount) {
|
||||
return 'No Screens';
|
||||
}
|
||||
|
||||
if (totalScreenCount > 0 && targetCount >= totalScreenCount) {
|
||||
return 'All Screens';
|
||||
}
|
||||
|
||||
return screenNames || 'No Screens';
|
||||
}
|
||||
|
||||
module.exports = function renderAnnouncementsPage(data, message, currentUser) {
|
||||
const announcements = (data.announcements || []).map(function (announcement) {
|
||||
const actionButton = formatActionButton(announcement);
|
||||
return Object.assign({}, announcement, {
|
||||
shortLabel: String(announcement.short_label || '').trim(),
|
||||
iconKey: String(announcement.icon_key || DEFAULT_ANNOUNCEMENT_ICON).trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON,
|
||||
iconLabel: formatIconLabel(announcement.icon_key),
|
||||
durationLabel: announcement.duration_seconds ? formatDurationLabel(announcement.duration_seconds) : 'Until disabled',
|
||||
expiresAtLabel: formatExpiresLabel(announcement.expires_at),
|
||||
screenTargetsLabel: formatScreenTargetsLabel(announcement),
|
||||
statusLabel: formatStatusLabel(announcement),
|
||||
actionButtonLabel: actionButton.label,
|
||||
actionButtonIcon: actionButton.icon,
|
||||
actionButtonClassName: actionButton.className,
|
||||
actionButtonConfirmMessage: actionButton.confirmMessage,
|
||||
actionButtonPath: actionButton.actionPath
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('signage/announcements/list', {
|
||||
title: 'Announcements',
|
||||
active: 'announcements',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
announcements: announcements,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,434 @@
|
||||
// Announcement route registration and status wiring.
|
||||
|
||||
module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const forwardAnnouncementRefresh = deps.forwardAnnouncementRefresh;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const announcementData = require('#src/data/announcements');
|
||||
const announcementIcons = require('#src/data/announcement-icons');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const ANNOUNCEMENT_TYPES = [
|
||||
{ value: 'lower-third', label: 'Lower third' },
|
||||
{ value: 'fullscreen', label: 'Fullscreen' },
|
||||
{ value: 'top-banner', label: 'Top banner' }
|
||||
];
|
||||
const ANNOUNCEMENT_COLORS = [
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'warning', label: 'Warning' },
|
||||
{ value: 'danger', label: 'Danger' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' }
|
||||
];
|
||||
const ANNOUNCEMENT_ICONS = announcementIcons.ANNOUNCEMENT_ICON_OPTIONS || [];
|
||||
|
||||
function normalizeDurationUnit(value) {
|
||||
return String(value || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
||||
}
|
||||
|
||||
function computeDurationSeconds(value, unit) {
|
||||
const normalizedUnit = normalizeDurationUnit(unit);
|
||||
const amount = Math.max(1, Math.floor(Number(value) || 0));
|
||||
return normalizedUnit === 'seconds' ? amount : amount * 60;
|
||||
}
|
||||
|
||||
function formatDurationLabel(seconds) {
|
||||
const totalSeconds = Math.max(1, Math.round(Number(seconds) || 0));
|
||||
if (totalSeconds % 60 === 0) {
|
||||
const minutes = totalSeconds / 60;
|
||||
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
||||
}
|
||||
return totalSeconds === 1 ? '1 second' : `${totalSeconds} seconds`;
|
||||
}
|
||||
|
||||
function isAnnouncementActive(announcement) {
|
||||
if (!announcement || !announcement.expires_at) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(announcement.expires_at).getTime();
|
||||
if (Number.isNaN(expiresAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiresAt > Date.now();
|
||||
}
|
||||
|
||||
function isAnnouncementDeletionBlocked(announcement) {
|
||||
const screenIds = Array.isArray(announcement && announcement.screen_ids) ? announcement.screen_ids : [];
|
||||
return isAnnouncementActive(announcement) || screenIds.length > 0;
|
||||
}
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
return (rows || [])
|
||||
.map(function (row) {
|
||||
return String(row && row.slug ? row.slug : '').trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchAnnouncementScreens() {
|
||||
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, slug ASC');
|
||||
return (rows || []).map(function (row) {
|
||||
return {
|
||||
id: Number(row && row.id) || null,
|
||||
name: String(row && row.name ? row.name : '').trim(),
|
||||
slug: String(row && row.slug ? row.slug : '').trim()
|
||||
};
|
||||
}).filter(function (screen) {
|
||||
return Boolean(screen.id && screen.slug);
|
||||
});
|
||||
}
|
||||
|
||||
function parseSelectedScreenIds(value) {
|
||||
const values = Array.isArray(value) ? value : value === undefined || value === null || value === '' ? [] : [value];
|
||||
return values.map(function (screenId) {
|
||||
return Number(screenId) || 0;
|
||||
}).filter(function (screenId) {
|
||||
return screenId > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function buildAnnouncementScreenOptions(screens, selectedScreenIds) {
|
||||
const normalizedSelectedIds = (Array.isArray(selectedScreenIds) ? selectedScreenIds : [])
|
||||
.map(function (screenId) {
|
||||
return Number(screenId) || 0;
|
||||
})
|
||||
.filter(function (screenId) {
|
||||
return screenId > 0;
|
||||
});
|
||||
const selectedIds = new Set(normalizedSelectedIds);
|
||||
|
||||
return (screens || []).map(function (screen) {
|
||||
return Object.assign({}, screen, {
|
||||
isSelected: selectedIds.has(Number(screen && screen.id) || 0)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function updateAnnouncementScreenTargets(announcementId, selectedScreenIds, actorId) {
|
||||
const [screenRows] = await pool.query('SELECT id FROM d_screens ORDER BY id ASC');
|
||||
const allScreenIds = (screenRows || []).map(function (row) {
|
||||
return Number(row && row.id) || 0;
|
||||
}).filter(function (screenId) {
|
||||
return screenId > 0;
|
||||
});
|
||||
const selectedIds = parseSelectedScreenIds(selectedScreenIds).filter(function (screenId) {
|
||||
return allScreenIds.indexOf(screenId) !== -1;
|
||||
});
|
||||
const [existingRows] = await pool.query('SELECT screen_id FROM d_announcement_screens WHERE announcement_id = ?', [announcementId]);
|
||||
const existingIds = new Set((existingRows || []).map(function (row) {
|
||||
return Number(row && row.screen_id) || 0;
|
||||
}).filter(function (screenId) {
|
||||
return screenId > 0;
|
||||
}));
|
||||
const selectedIdSet = new Set(selectedIds);
|
||||
const removedIds = (existingRows || []).map(function (row) {
|
||||
return Number(row && row.screen_id) || 0;
|
||||
}).filter(function (screenId) {
|
||||
return screenId > 0 && !selectedIdSet.has(screenId);
|
||||
});
|
||||
|
||||
if (removedIds.length) {
|
||||
await pool.query('DELETE FROM d_announcement_screens WHERE announcement_id = ? AND screen_id IN (?)', [announcementId, removedIds]);
|
||||
}
|
||||
|
||||
if (selectedIds.length) {
|
||||
await pool.query(
|
||||
'INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by) VALUES ? ON DUPLICATE KEY UPDATE modified_at = CURRENT_TIMESTAMP, modified_by = VALUES(modified_by)',
|
||||
[selectedIds.map(function (screenId) {
|
||||
return [announcementId, screenId, actorId || null, actorId || null];
|
||||
})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAnnouncementPlayers() {
|
||||
if (typeof forwardAnnouncementRefresh !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const slugs = await fetchAllScreenSlugs();
|
||||
if (!slugs.length) {
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled(slugs.map(function (slug) {
|
||||
return forwardAnnouncementRefresh(slug);
|
||||
}));
|
||||
} catch (_error) {
|
||||
// Keep announcement persistence independent from player refresh delivery.
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateConflictingAnnouncements(announcementId, screenIds, actorId) {
|
||||
const normalizedScreenIds = (Array.isArray(screenIds) ? screenIds : [])
|
||||
.map(function (screenId) {
|
||||
return Number(screenId) || 0;
|
||||
})
|
||||
.filter(function (screenId) {
|
||||
return screenId > 0;
|
||||
});
|
||||
|
||||
if (!normalizedScreenIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictingIds = new Set();
|
||||
const [targetedRows] = await pool.query(
|
||||
`SELECT DISTINCT a.id
|
||||
FROM d_announcements a
|
||||
JOIN d_announcement_screens restriction ON restriction.announcement_id = a.id
|
||||
WHERE a.id <> ?
|
||||
AND (a.expires_at IS NULL OR a.expires_at > CURRENT_TIMESTAMP)
|
||||
AND restriction.screen_id IN (?)`,
|
||||
[announcementId, normalizedScreenIds]
|
||||
);
|
||||
|
||||
(targetedRows || []).forEach(function (row) {
|
||||
const id = Number(row && row.id) || 0;
|
||||
if (id > 0) {
|
||||
conflictingIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (!conflictingIds.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE d_announcements SET expires_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id IN (?)',
|
||||
[actorId || null, Array.from(conflictingIds)]
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnnouncementFormData(announcement) {
|
||||
const normalized = announcement || {};
|
||||
const durationSeconds = Number(normalized.duration_seconds || 0) || 0;
|
||||
const durationValue = durationSeconds > 0 && durationSeconds % 60 === 0
|
||||
? durationSeconds / 60
|
||||
: durationSeconds;
|
||||
const durationUnit = durationSeconds > 0 && durationSeconds % 60 === 0 ? 'minutes' : 'seconds';
|
||||
const durationMode = String(normalized.durationMode || '').trim() || (durationSeconds > 0 ? 'duration' : 'until_disabled');
|
||||
const isActive = isAnnouncementActive(normalized);
|
||||
const canDelete = Boolean(normalized.id) && !isAnnouncementDeletionBlocked(normalized);
|
||||
|
||||
return Object.assign({}, normalized, {
|
||||
durationMode: durationMode,
|
||||
durationValue: durationSeconds > 0 ? durationValue : Number(normalized.durationValue || 5) || 5,
|
||||
durationUnit: durationSeconds > 0 ? durationUnit : String(normalized.durationUnit || 'minutes'),
|
||||
durationLabel: durationSeconds > 0 ? formatDurationLabel(durationSeconds) : 'Until disabled',
|
||||
isActive: isActive,
|
||||
actionLabel: isActive ? 'Stop' : 'Play',
|
||||
actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
actionClassName: isActive ? 'btn-outline-warning' : 'btn-outline-success',
|
||||
actionConfirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
actionPath: isActive
|
||||
? `/announcements/${normalized.id}/stop`
|
||||
: `/announcements/${normalized.id}/play`,
|
||||
canDelete: canDelete,
|
||||
deleteBlockedMessage: canDelete ? '' : 'This announcement is still in use by one or more screens.'
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/announcements', requirePermission('announcements.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req) || 'description';
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await common.fetchAnnouncementsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
res.send(pages.renderAnnouncementsPage({
|
||||
announcements: data.announcements || [],
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'announcements', 'Announcement pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) {
|
||||
try {
|
||||
const screens = await fetchAnnouncementScreens();
|
||||
res.send(pages.renderAnnouncementFormPage(buildAnnouncementFormData({
|
||||
message: '',
|
||||
short_label: '',
|
||||
announcement_type: 'lower-third',
|
||||
color_key: 'primary',
|
||||
icon_key: announcementIcons.DEFAULT_ANNOUNCEMENT_ICON,
|
||||
durationMode: 'duration',
|
||||
duration_seconds: null,
|
||||
durationValue: 5,
|
||||
durationUnit: 'minutes',
|
||||
screen_ids: []
|
||||
}), 'add', req.query.message ? String(req.query.message) : '', req.currentUser, {
|
||||
announcementTypes: ANNOUNCEMENT_TYPES,
|
||||
announcementColors: ANNOUNCEMENT_COLORS,
|
||||
announcementIcons: ANNOUNCEMENT_ICONS,
|
||||
announcementScreens: buildAnnouncementScreenOptions(screens, [])
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/announcements/:id/edit', requirePermission('announcements.update'), async function (req, res, next) {
|
||||
try {
|
||||
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
|
||||
if (!announcement) {
|
||||
return res.status(404).send('Announcement not found');
|
||||
}
|
||||
const screens = await fetchAnnouncementScreens();
|
||||
res.send(pages.renderAnnouncementEditPage(buildAnnouncementFormData(announcement), null, req.query.message ? String(req.query.message) : '', req.currentUser, {
|
||||
announcementTypes: ANNOUNCEMENT_TYPES,
|
||||
announcementColors: ANNOUNCEMENT_COLORS,
|
||||
announcementIcons: ANNOUNCEMENT_ICONS,
|
||||
announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || [])
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/announcements', requirePermission('announcements.create'), async function (req, res, next) {
|
||||
try {
|
||||
const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim();
|
||||
const shortLabel = String(req.body.short_label || '').replace(/\r\n/g, ' ').trim();
|
||||
const announcementType = common.normalizeAnnouncementType(req.body.announcement_type);
|
||||
const colorKey = common.normalizeAnnouncementColor(req.body.color_key);
|
||||
const iconKey = common.normalizeAnnouncementIcon(req.body.icon_key);
|
||||
const durationMode = String(req.body.duration_mode || 'until_disabled').trim();
|
||||
const durationSeconds = durationMode === 'duration'
|
||||
? computeDurationSeconds(req.body.duration_value, req.body.duration_unit)
|
||||
: null;
|
||||
const selectedScreenIds = parseSelectedScreenIds(req.body.screen_ids);
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).send('Announcement text is required.');
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO d_announcements (message, short_label, announcement_type, color_key, icon_key, duration_seconds, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)',
|
||||
[message, shortLabel, announcementType, colorKey, iconKey, durationSeconds, actorId, actorId]
|
||||
);
|
||||
await updateAnnouncementScreenTargets(result.insertId, selectedScreenIds, actorId);
|
||||
redirectAfterSave(req, res, '/announcements?edit=' + result.insertId, {
|
||||
closeUrl: '/announcements',
|
||||
newUrl: '/announcements/new',
|
||||
message: 'Announcement created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/announcements/:id', requirePermission('announcements.update'), async function (req, res, next) {
|
||||
try {
|
||||
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
|
||||
if (!announcement) {
|
||||
return res.status(404).send('Announcement not found');
|
||||
}
|
||||
|
||||
const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim();
|
||||
const shortLabel = String(req.body.short_label || '').replace(/\r\n/g, ' ').trim();
|
||||
const announcementType = common.normalizeAnnouncementType(req.body.announcement_type);
|
||||
const colorKey = common.normalizeAnnouncementColor(req.body.color_key);
|
||||
const iconKey = common.normalizeAnnouncementIcon(req.body.icon_key);
|
||||
const durationMode = String(req.body.duration_mode || 'until_disabled').trim();
|
||||
const durationSeconds = durationMode === 'duration'
|
||||
? computeDurationSeconds(req.body.duration_value, req.body.duration_unit)
|
||||
: null;
|
||||
const selectedScreenIds = parseSelectedScreenIds(req.body.screen_ids);
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).send('Announcement text is required.');
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query(
|
||||
'UPDATE d_announcements SET message = ?, short_label = ?, announcement_type = ?, color_key = ?, icon_key = ?, duration_seconds = ?, modified_by = ? WHERE id = ?',
|
||||
[message, shortLabel, announcementType, colorKey, iconKey, durationSeconds, actorId, announcement.id]
|
||||
);
|
||||
await updateAnnouncementScreenTargets(announcement.id, selectedScreenIds, actorId);
|
||||
redirectAfterSave(req, res, '/announcements?edit=' + announcement.id, {
|
||||
closeUrl: '/announcements',
|
||||
newUrl: '/announcements/new',
|
||||
message: 'Announcement updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/announcements/:id/play', requirePermission('announcements.update'), async function (req, res, next) {
|
||||
try {
|
||||
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
|
||||
if (!announcement) {
|
||||
return res.status(404).send('Announcement not found');
|
||||
}
|
||||
|
||||
await deactivateConflictingAnnouncements(announcement.id, announcement.screen_ids || [], getAuditUserId(req));
|
||||
const durationSeconds = announcement.duration_seconds ? Number(announcement.duration_seconds) : null;
|
||||
const expiresAt = durationSeconds ? new Date(Date.now() + (durationSeconds * 1000)) : null;
|
||||
await pool.query('UPDATE d_announcements SET expires_at = ?, modified_by = ? WHERE id = ?', [expiresAt, getAuditUserId(req), announcement.id]);
|
||||
await refreshAnnouncementPlayers();
|
||||
if (req.xhr || String(req.headers.accept || '').includes('application/json')) {
|
||||
return res.json({ ok: true, message: 'Announcement sent to screens.', announcementId: announcement.id });
|
||||
}
|
||||
|
||||
res.redirect('/announcements?edit=' + announcement.id + '&message=' + encodeURIComponent('Announcement sent to screens.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/announcements/:id/stop', requirePermission('announcements.update'), async function (req, res, next) {
|
||||
try {
|
||||
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
|
||||
if (!announcement) {
|
||||
return res.status(404).send('Announcement not found');
|
||||
}
|
||||
|
||||
await pool.query('UPDATE d_announcements SET expires_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ?', [getAuditUserId(req), announcement.id]);
|
||||
await refreshAnnouncementPlayers();
|
||||
if (req.xhr || String(req.headers.accept || '').includes('application/json')) {
|
||||
return res.json({ ok: true, message: 'Announcement stopped.', announcementId: announcement.id });
|
||||
}
|
||||
|
||||
res.redirect('/announcements?edit=' + announcement.id + '&message=' + encodeURIComponent('Announcement stopped.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/announcements/:id/delete', requirePermission('announcements.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
|
||||
if (!announcement) {
|
||||
return res.status(404).send('Announcement not found');
|
||||
}
|
||||
|
||||
if (isAnnouncementDeletionBlocked(announcement)) {
|
||||
return res.redirect('/announcements/' + announcement.id + '/edit?message=' + encodeURIComponent('This announcement is still in use by one or more screens.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM d_announcements WHERE id = ?', [announcement.id]);
|
||||
await refreshAnnouncementPlayers();
|
||||
res.redirect('/announcements?message=' + encodeURIComponent('Announcement deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// Canvas size add page renderer and form defaults.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultCanvasSize() {
|
||||
@@ -16,6 +18,7 @@ module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, cu
|
||||
active: 'canvas-sizes',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
canvasSize: canvasSize || buildDefaultCanvasSize()
|
||||
canvasSize: canvasSize || buildDefaultCanvasSize(),
|
||||
inUse: Boolean(canvasSize && canvasSize.inUse)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Canvas size edit page renderer that reuses the add form.
|
||||
|
||||
const renderCanvasSizeFormPage = require('./add');
|
||||
|
||||
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Canvas size list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderCanvasSizesPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Canvas size route registration and pagination wiring.
|
||||
|
||||
module.exports = function registerCanvasSizeRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
@@ -5,7 +7,7 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Connected clients list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderConnectedClientsPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Connected client route registration and dashboard wiring.
|
||||
|
||||
module.exports = function registerClientsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
@@ -7,7 +9,7 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function sortClients(clients, sortKey, sortDirection) {
|
||||
const normalizedSortKey = String(sortKey || '').trim();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Dashboard page renderer for the signage overview.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Dashboard route registration for the signage overview page.
|
||||
|
||||
module.exports = function registerDashboardRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Playlist add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderPlaylistFormPage(message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Playlist edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatDateTime(value) {
|
||||
@@ -208,6 +210,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlist: playlist,
|
||||
inUse: Boolean(playlist.inUse),
|
||||
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||
playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||
playlistSlides: playlistSlides,
|
||||
@@ -218,6 +221,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
selectableSlides: selectableSlides,
|
||||
playlistCanvasSignature: playlistCanvasSignature,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
scripts: ['js/lib/modal.js?v=20260726.7', 'js/vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
|
||||
scripts: ['js/lib/modal.js?v=20260726.7', 'vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Playlist list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
||||
@@ -5,9 +7,13 @@ module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
||||
const slideCount = playlist.slide_count !== undefined
|
||||
? Number(playlist.slide_count) || 0
|
||||
: (data.playlistSlides || []).filter((item) => item.playlist_id === playlist.id).length;
|
||||
const screenCount = playlist.screen_count !== undefined
|
||||
? Number(playlist.screen_count) || 0
|
||||
: 0;
|
||||
return Object.assign({}, playlist, {
|
||||
slideCount: slideCount,
|
||||
slideCountIsOne: slideCount === 1
|
||||
screenCount: screenCount,
|
||||
screenCountIsOne: screenCount === 1
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Playlist route registration and pagination wiring.
|
||||
|
||||
module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
@@ -26,6 +29,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
playlist.inUse = (await fetchScreensByPlaylistId(pool, playlist.id)).length > 0;
|
||||
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
@@ -49,6 +53,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
playlist.inUse = (await fetchScreensByPlaylistId(pool, playlist.id)).length > 0;
|
||||
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared playlist slide configuration rendering helpers.
|
||||
|
||||
const { renderFragment } = require('../../../view');
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Screen add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderScreenFormPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Screen edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
|
||||
@@ -7,6 +9,7 @@ module.exports = function renderScreenEditPage(screen, data, message, currentUse
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
screen: screen,
|
||||
inUse: Boolean(screen.inUse),
|
||||
playlists: data.playlists || []
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Screen list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderScreensPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// Screen route registration and dashboard wiring.
|
||||
|
||||
module.exports = function registerScreensRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
@@ -18,6 +21,7 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
function applyConnectionCounts(screens, dashboardScreens) {
|
||||
const countsById = new Map();
|
||||
const countsBySlug = new Map();
|
||||
const dashboardScreenBySlug = new Map();
|
||||
|
||||
(dashboardScreens || []).forEach(function (screen) {
|
||||
const count = Number(screen && screen.player_connection_count || 0);
|
||||
@@ -29,18 +33,24 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
}
|
||||
if (slug) {
|
||||
countsBySlug.set(slug, count);
|
||||
dashboardScreenBySlug.set(slug, screen);
|
||||
}
|
||||
if (id) {
|
||||
dashboardScreenBySlug.set(id, screen);
|
||||
}
|
||||
});
|
||||
|
||||
return (screens || []).map(function (screen) {
|
||||
const slug = String(screen && screen.slug || '').trim();
|
||||
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
|
||||
const dashboardScreen = dashboardScreenBySlug.get(slug) || dashboardScreenBySlug.get(id) || null;
|
||||
const playerConnectionCount = countsBySlug.has(slug)
|
||||
? countsBySlug.get(slug)
|
||||
: countsById.get(id) || 0;
|
||||
|
||||
return Object.assign({}, screen, {
|
||||
player_connection_count: playerConnectionCount
|
||||
player_connection_count: playerConnectionCount,
|
||||
player_url: dashboardScreen && dashboardScreen.player_url ? dashboardScreen.player_url : screen.player_url || null
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -52,6 +62,7 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
@@ -60,9 +71,18 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const dashboardState = await buildDashboardState(pool);
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
res.send(pages.renderScreensPage({
|
||||
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
|
||||
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []).map(function (screen) {
|
||||
const slug = String(screen && screen.slug || '').trim();
|
||||
const registryUrl = slug && playerUrlsBySlug[slug] ? String(playerUrlsBySlug[slug]).trim() : '';
|
||||
return Object.assign({}, screen, {
|
||||
player_url: screen.player_url || registryUrl || null
|
||||
});
|
||||
}),
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
@@ -76,6 +96,11 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
screen.player_url = screen.slug && playerUrlsBySlug[screen.slug] ? String(playerUrlsBySlug[screen.slug]).trim() : null;
|
||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// Slide form page renderer for add and edit flows.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||
|
||||
module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) {
|
||||
const assetVersion = Date.now().toString(36);
|
||||
const templateRegions = data.templateRegions || [];
|
||||
const rssFeeds = data.rssFeeds || [];
|
||||
const apiSources = data.apiSources || [];
|
||||
const fontLibrary = data.fontLibrary || null;
|
||||
const templates = (data.templates || []).map((template) => ({
|
||||
...template,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
@@ -15,18 +20,23 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
|
||||
active: 'slides',
|
||||
message: message,
|
||||
isEdit: mode === 'edit',
|
||||
inUse: Boolean(slide && slide.inUse),
|
||||
saveLabel: 'Save',
|
||||
action: mode === 'edit' ? `/slides/${slide.id}` : '/slides',
|
||||
slide: slide || { title: '', template_id: null, content: {} },
|
||||
templates: templates,
|
||||
stylesheets: ['js/vendor/ckeditor5/ckeditor5.css'],
|
||||
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
|
||||
slideEditorData: {
|
||||
templates: templates,
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
|
||||
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
|
||||
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
|
||||
existingContent: slide && slide.content ? slide.content : {}
|
||||
},
|
||||
assetVersion: assetVersion,
|
||||
slideEditorScripts: getRegionEditorScripts(assetVersion),
|
||||
currentUser: currentUser || null
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Slide list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderSlidesPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Slide route registration and pagination wiring.
|
||||
|
||||
const { renderFragment } = require('../../../view');
|
||||
|
||||
module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
@@ -5,7 +9,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
try {
|
||||
@@ -22,5 +26,14 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/popup-preview', requirePermission('slides.read'), function (_req, res) {
|
||||
res.send(renderFragment('slides/popup-preview', {
|
||||
title: 'Slide preview',
|
||||
framePopupCard: true,
|
||||
hideFrameHeader: true,
|
||||
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body'
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Template add page renderer and form defaults.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||
|
||||
function buildDefaultTemplate() {
|
||||
return {
|
||||
@@ -43,6 +46,7 @@ function resolveTemplateCanvasSize(template, canvasSizes) {
|
||||
module.exports = function renderTemplateFormPage(template, mode, message, canvasSizes, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
|
||||
const assetVersion = Date.now().toString(36);
|
||||
|
||||
return renderView(isEdit ? 'templates/edit' : 'templates/add', {
|
||||
title: isEdit ? 'Edit template' : 'Create Template',
|
||||
@@ -50,7 +54,8 @@ module.exports = function renderTemplateFormPage(template, mode, message, canvas
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
template: current,
|
||||
inUse: Boolean(current.inUse),
|
||||
canvasSizes: canvasSizes || [],
|
||||
scripts: ['js/lib/modal.js', 'js/templates/template-designer-utils.js', 'js/templates/template-designer.js']
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Template edit page renderer that reuses the add form.
|
||||
|
||||
const renderTemplateFormPage = require('./add');
|
||||
|
||||
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Template list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderTemplatesPage(data, message, currentUser) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Template route registration and pagination wiring.
|
||||
|
||||
module.exports = function registerTemplatesRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
@@ -5,7 +7,7 @@ module.exports = function registerTemplatesRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user