Release v2.4.2

This commit is contained in:
2026-08-03 12:45:27 +01:00
parent 2b9cabdab2
commit e95928f31c
141 changed files with 7694 additions and 5078 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ module.exports = function registerAccountRoutes(app, deps) {
const setSessionCookie = deps.setSessionCookie;
app.get('/account', function (req, res) {
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard'));
});
app.post('/account/name', async function (req, res, next) {
@@ -32,7 +32,7 @@ module.exports = function registerAccountRoutes(app, deps) {
return res.status(404).send('User not found.');
}
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.'));
} catch (error) {
next(error);
}
@@ -68,7 +68,7 @@ module.exports = function registerAccountRoutes(app, deps) {
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
res.redirect('/dashboard?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
+99
View File
@@ -1,12 +1,16 @@
// Admin client command routes for connected screens.
const { commitDeviceBinding } = require('#src/player/onboarding');
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const requirePermission = deps.requirePermission;
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
@@ -138,6 +142,101 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
});
}
if (command === 'moveclient') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
if (!targetScreenSlug) {
return res.status(400).json({ error: 'Target screen is required' });
}
const [currentRows] = await pool.query(
`SELECT d.client_name, s.slug AS current_screen_slug
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?
LIMIT 1`,
[deviceId]
);
const onboardingRow = currentRows[0] || null;
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
if (!resolvedClientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: resolvedClientName,
targetScreenSlug: targetScreenSlug,
ok: true,
unchanged: true
});
}
if (typeof commitDeviceBinding !== 'function') {
return res.status(500).json({ error: 'Client binding is unavailable.' });
}
let liveConnections = [];
try {
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
const screenSlug = String(row && row.slug ? row.slug : '').trim();
if (!screenSlug || typeof getScreenConnections !== 'function') {
return [];
}
try {
const liveResponse = await getScreenConnections(screenSlug);
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
} catch (_error) {
return [];
}
}));
liveConnections = liveResults.flat();
} catch (_error) {
liveConnections = [];
}
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
: null;
const targetBaseUrl = String(targetPlayerRecord && targetPlayerRecord.public_base_url || playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
await forwardPlayerCommand(slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: status ? status.client_name : resolvedClientName,
targetScreenSlug: targetScreenSlug,
playerUrl: targetPlayerUrl,
ok: true
});
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: { command: command };
+38 -44
View File
@@ -2,7 +2,8 @@
const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('../../lib/media/font-library');
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
@@ -27,12 +28,39 @@ module.exports = function registerContentRoutes(app, deps) {
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
const requirePermission = deps.requirePermission;
const hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const LIST_PAGE_SIZE = 25;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
async function fetchSlideFormData() {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
return Object.assign(data, rssData, apiData, timetableData, {
rssFeeds: rssFeeds,
apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [],
fontLibrary: loadFontLibrary(deps.uploadDir)
});
}
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('registerContentRoutes requires the content route dependencies.');
}
@@ -68,7 +96,7 @@ module.exports = function registerContentRoutes(app, deps) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -271,25 +299,8 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
const data = await fetchSlideFormData();
res.send(pages.renderSlideAddPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -302,25 +313,8 @@ module.exports = function registerContentRoutes(app, deps) {
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: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
const data = await fetchSlideFormData();
res.send(pages.renderSlideEditPage(data, slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -484,7 +478,7 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
} catch (error) {
next(error);
}
@@ -536,7 +530,7 @@ module.exports = function registerContentRoutes(app, deps) {
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));
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -642,7 +636,7 @@ module.exports = function registerContentRoutes(app, deps) {
});
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
+7 -826
View File
@@ -1,172 +1,10 @@
// Admin data-source route registration for API and RSS sources.
// Root data-source redirect only.
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerDataSourceRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
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 parseDateTimeLocal = deps.parseDateTimeLocal;
const { buildPagination } = require('#src/web/lib/pagination');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const LIST_PAGE_SIZE = 25;
function toIsoTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
function slideUsesDataSourceField(content, fieldName, targetId) {
const normalizedTargetId = Number(targetId);
if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') {
return false;
}
if (Array.isArray(content)) {
return content.some(function (item) {
return slideUsesDataSourceField(item, fieldName, normalizedTargetId);
});
}
if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) {
return true;
}
return Object.keys(content).some(function (key) {
return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId);
});
}
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.';
}
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();
const scheduleGroupIds = 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);
}
}
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
const scheduleGroupId = Number(value.schedule_group_id);
if (Number.isFinite(scheduleGroupId)) {
scheduleGroupIds.add(scheduleGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return {
apiSourceIds: apiSourceIds,
rssFeedIds: rssFeedIds,
scheduleGroupIds: scheduleGroupIds
};
}
function readScheduleArrayField(body, key) {
if (!body || !Object.prototype.hasOwnProperty.call(body, key)) {
return [];
}
const value = body[key];
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
}
const dataSourceTasks = createDataSourceTaskService({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue
});
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 = 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.' });
}
res.json(task);
}
app.get('/data-sources', function (req, res, next) {
if (!req.currentUser) {
@@ -177,9 +15,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['schedules.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['schedules.read'])) {
return res.redirect('/data-sources/schedules');
if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['timetables.read'])) {
return res.redirect('/data-sources/timetables');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
return res.redirect('/data-sources/rss-feeds');
@@ -187,666 +25,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/data-sources/api-sources');
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
});
app.get('/data-sources/api-sources', requirePermission('api-sources.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);
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() : '',
inUse: usageMaps.apiSourceIds.has(Number(apiSource.id))
});
});
res.send(pages.renderApiSourcesPage({
apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildApiSourcePayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'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();
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,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'API source created. Refresh is running in the background.';
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
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),
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
lastPullError: apiSource.last_pull_error || '',
lastResponseStatus: apiSource.last_response_status,
lastResponseContentType: apiSource.last_response_content_type,
lastResponseJson: apiSource.last_response_json || '',
inUse: inUse
}), {
lastResponseJson: apiSource.last_response_json || '',
lastPullError: apiSource.last_pull_error || ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
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) {
const connection = await pool.getConnection();
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const payload = common.buildApiSourcePayload(req, apiSource);
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 = ?, 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();
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({
key: 'api-source-refresh:' + apiSource.id,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
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();
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.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);
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: 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) {
next(error);
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
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);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
);
await connection.commit();
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,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'RSS feed created. Refresh is running in the background.';
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
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)
: [];
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit,
inUse: inUse
}), {
pulledItems: pulledItems,
pullError: ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
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) {
const connection = await pool.getConnection();
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const payload = common.buildRssFeedPayload(req, rssFeed);
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);
await connection.beginTransaction();
await connection.query(
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
);
await connection.commit();
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({
key: 'rss-feed-refresh:' + rssFeed.id,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
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();
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules', requirePermission('schedules.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);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchScheduleGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
inUse: usageMaps.scheduleGroupIds.has(Number(scheduleGroup.id))
});
});
res.send(pages.renderScheduleGroupsPage({
scheduleGroups: scheduleGroups,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Schedules', 'Schedule groups')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules/new', requirePermission('schedules.create'), function (req, res) {
res.send(pages.renderScheduleGroupFormPage(null, [], 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
function readScheduleArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null) {
return [value];
}
}
return [];
}
function buildScheduleEntryRows(req) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
error.statusCode = 400;
throw error;
}
const rows = [];
for (let index = 0; index < titles.length; index += 1) {
const idValue = Number(ids[index]);
const title = String(titles[index] || '').trim();
const shortDescription = String(descriptions[index] || '').trim();
const startDatetime = parseDateTimeLocal(starts[index]);
const endDatetime = parseDateTimeLocal(ends[index]);
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
if (!hasContent) {
continue;
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
rows.push({
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
title: title,
shortDescription: shortDescription,
startDatetime: startDatetime,
endDatetime: endDatetime
});
}
return rows;
}
app.post('/data-sources/schedules', requirePermission('schedules.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildScheduleGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
return res.redirect('/data-sources/schedules/new?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
result.insertId,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/schedules/' + result.insertId + '/edit', {
closeUrl: '/data-sources/schedules',
newUrl: '/data-sources/schedules/new',
message: 'Schedule group created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/schedules/:id/edit', requirePermission('schedules.update'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const inUse = await getDataSourceInUse('schedule_group_id', scheduleGroup.id);
const scheduleEntries = typeof common.fetchScheduleEntriesByGroupId === 'function'
? await common.fetchScheduleEntriesByGroupId(pool, scheduleGroup.id)
: [];
res.send(pages.renderScheduleGroupFormPage(Object.assign({}, scheduleGroup, {
shortDescription: scheduleGroup.short_description || '',
inUse: inUse
}), scheduleEntries, 'edit', req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/data-sources/schedules/:id', requirePermission('schedules.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const payload = common.buildScheduleGroupPayload(req, scheduleGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, scheduleGroup.id)) {
return res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, scheduleGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [scheduleGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
scheduleGroup.id,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
const message = 'Schedule group updated.';
res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent(message));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/schedules/:id/delete', requirePermission('schedules.delete'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('schedule_group_id', scheduleGroup.id, 'schedule group');
if (blockMessage) {
return res.redirect('/data-sources/schedules?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [scheduleGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/schedules?message=' + encodeURIComponent('Schedule group deleted.'));
} catch (error) {
next(error);
}
});
};
+1 -466
View File
@@ -1,24 +1,15 @@
// Admin manage routes for screens, playlists, slides, and templates.
// Admin manage routes for screens and commands.
module.exports = function registerManageRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature;
const getCanvasSignature = deps.getCanvasSignature;
const normalizeScheduleMode = deps.normalizeScheduleMode;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const parseTimeLocal = deps.parseTimeLocal;
const readArrayField = deps.readArrayField;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const getScreenConnections = deps.getScreenConnections;
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
const requirePermission = deps.requirePermission;
@@ -83,462 +74,6 @@ module.exports = function registerManageRoutes(app, deps) {
}
});
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
return res.status(400).send('A playlist with that name already exists.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
});
} catch (error) {
next(error);
}
});
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']);
const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']);
const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']);
if (durations.length && durations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
return res.status(400).send('Playlist schedule data is invalid.');
}
const normalizedSlides = [];
const seenSlideIds = new Set();
for (let i = 0; i < slideIds.length; i += 1) {
const slideId = Number(slideIds[i]);
if (!Number.isInteger(slideId) || slideId <= 0) {
return res.status(400).send('Invalid slide selection.');
}
if (seenSlideIds.has(slideId)) {
return res.status(400).send('A slide can only be added to a playlist once.');
}
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]);
scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
return res.status(400).send('Start and end datetimes are required for date scheduling.');
}
if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]);
scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]);
if (!scheduleStartTime || !scheduleEndTime) {
return res.status(400).send('Start and end times are required for time scheduling.');
}
let scheduleDays = [];
try {
const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]'));
scheduleDays = Array.isArray(parsedDays) ? parsedDays : [];
} catch (_error) {
scheduleDays = [];
}
scheduleDays = scheduleDays
.map(function (value) { return Number(value); })
.filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; });
if (!scheduleDays.length) {
return res.status(400).send('Select at least one day for time scheduling.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
normalizedSlides.push({
slideId,
position: i,
durationSeconds,
useVideoDuration,
scheduleMode,
scheduleStartDatetime,
scheduleEndDatetime,
scheduleStartTime,
scheduleEndTime,
scheduleDaysJson
});
}
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_slides sl
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE sl.id IN (?)`,
[normalizedSlides.map(function (item) { return item.slideId; })]
);
if (slides.length !== normalizedSlides.length) {
return res.status(400).send('One or more selected slides no longer exist.');
}
const signatures = Array.from(new Set(
slides
.map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); })
.filter(Boolean)
));
if (signatures.length > 1) {
return res.status(400).send('All playlist slides must share the same canvas size.');
}
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
playlist.id,
item.slideId,
item.position,
item.durationSeconds,
item.useVideoDuration,
item.scheduleMode,
item.scheduleStartDatetime,
item.scheduleEndDatetime,
item.scheduleStartTime,
item.scheduleEndTime,
item.scheduleDaysJson,
actorId,
actorId
]
);
}
await connection.commit();
await notifyPlayerScreens(affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
if (blockMessage) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const slideId = Number(req.body.slide_id);
if (!slideId) {
return res.status(400).send('Slide is required.');
}
const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id);
if (playlistCanvasSignature === 'mismatch') {
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
return res.status(404).send('Slide not found');
}
const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height);
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const direction = String(req.body.direction || '').toLowerCase();
if (direction !== 'up' && direction !== 'down') {
return res.status(400).send('Invalid move direction.');
}
await connection.beginTransaction();
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
const currentIndex = orderedSlides.findIndex(function (item) {
return Number(item.id) === playlistSlideId;
});
if (currentIndex === -1) {
await connection.rollback();
return res.status(404).send('Playlist slide not found');
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
let playlistSlide = (data.playlistSlides || []).find(function (item) {
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
});
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
return res.status(404).send('Playlist slide not found');
}
const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always');
const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null;
const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null;
const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null;
const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null;
const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]';
const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || [];
playlistSlide = Object.assign({}, playlistSlide || {}, {
id: playlistSlide ? playlistSlide.id : 0,
schedule_mode: scheduleMode,
schedule_days_json: scheduleDaysJson,
schedule_days: scheduleDays,
schedule_start_datetime: scheduleStartDatetime,
schedule_end_datetime: scheduleEndDatetime,
schedule_start_time: scheduleStartTime,
schedule_end_time: scheduleEndTime
});
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const rowKey = String(req.body.row_key || '').trim();
if (rowKey) {
return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.');
}
const playlistSlideId = Number(req.params.playlistSlideId);
let scheduleMode = normalizeScheduleMode(req.body.schedule_mode);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime);
const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time);
const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length);
if (scheduleMode === 'dates' && !hasDateRange) {
scheduleMode = 'always';
} else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) {
scheduleMode = 'always';
}
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime);
scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
scheduleMode = 'always';
scheduleStartDatetime = null;
scheduleEndDatetime = null;
} else if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(req.body.schedule_start_time);
scheduleEndTime = parseTimeLocal(req.body.schedule_end_time);
const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value >= 0 && value <= 6;
});
if (!scheduleStartTime || !scheduleEndTime) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else if (!scheduleDays.length) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else {
if (scheduleEndTime < scheduleStartTime) {
return res.status(400).send('End time must be after start time.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
try {
const data = await common.fetchScreenEditData(pool);
+642
View File
@@ -0,0 +1,642 @@
// Playlist admin routes and playlist-slide management.
module.exports = function registerPlaylistRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId;
const readArrayField = deps.readArrayField;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const requirePermission = deps.requirePermission;
function readRawFieldArray(reqBody, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
if (!reqBody || !Object.prototype.hasOwnProperty.call(reqBody, key)) {
continue;
}
const value = reqBody[key];
if (Array.isArray(value)) {
return value.map(function (item) {
return item === undefined || item === null ? '' : String(item);
});
}
return [value === undefined || value === null ? '' : String(value)];
}
return [];
}
function normalizeScheduleRuleValuesFromBody(reqBody, index) {
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
const startDatetime = String(startDatetimeValues[index] || '').trim() || null;
const endDatetime = String(endDatetimeValues[index] || '').trim() || null;
const startTime = String(startTimeValues[index] || '').trim() || null;
const endTime = String(endTimeValues[index] || '').trim() || null;
const daysCsv = String(daysCsvValues[index] || '').trim();
const days = daysCsv
? Array.from(new Set(daysCsv.split(',').map(function (value) { return Number(value); }).filter(function (day) {
return Number.isInteger(day) && day >= 0 && day <= 6;
}))).sort(function (left, right) { return left - right; })
: [];
return {
start_datetime: startDatetime,
end_datetime: endDatetime,
start_time: startTime,
end_time: endTime,
schedule_days_json: days.length ? JSON.stringify(days) : null
};
}
function buildScheduleRulesFromRequestBody(reqBody) {
const rowKeys = readRawFieldArray(reqBody, ['schedule_rule_row_key[]', 'schedule_rule_row_key']);
const positions = readRawFieldArray(reqBody, ['schedule_rule_position[]', 'schedule_rule_position']);
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
const lengths = [rowKeys.length, positions.length, startDatetimeValues.length, endDatetimeValues.length, startTimeValues.length, endTimeValues.length, daysCsvValues.length]
.filter(function (value) { return value > 0; });
if (lengths.length && lengths.some(function (length) { return length !== lengths[0]; })) {
return null;
}
return rowKeys.map(function (rowKey, index) {
const rule = normalizeScheduleRuleValuesFromBody(reqBody, index);
if (!rule.start_datetime && !rule.end_datetime && !rule.start_time && !rule.end_time && !rule.schedule_days_json) {
return null;
}
return {
rowKey: String(rowKey || '').trim(),
position: Number(positions[index] || index) || 0,
startDatetime: rule.start_datetime,
endDatetime: rule.end_datetime,
startTime: rule.start_time,
endTime: rule.end_time,
daysJson: rule.schedule_days_json
};
}).filter(Boolean);
}
async function replacePlaylistSlideScheduleRules(connection, playlistSlideId, scheduleRules, actorId) {
await connection.query('DELETE FROM c_playlist_slide_schedule_rules WHERE playlist_slide_id = ?', [playlistSlideId]);
for (let index = 0; index < scheduleRules.length; index += 1) {
const rule = scheduleRules[index];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlistSlideId, rule.position || index, rule.startDatetime, rule.endDatetime, rule.startTime, rule.endTime, rule.daysJson, actorId, actorId]
);
}
}
function createValidationError(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
async function savePlaylistItems(connection, playlist, reqBody, actorId, options) {
const shouldUpdatePlaylist = !options || options.updatePlaylist !== false;
const name = String(reqBody.name || '').trim();
const fadeBetweenSlides = reqBody.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = reqBody.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(reqBody.canvas_size_id);
let requestedCanvasId = null;
if (!name) {
throw createValidationError('Playlist name is required.');
}
if (shouldUpdatePlaylist && reqBody.canvas_size_id !== undefined && reqBody.canvas_size_id !== null && String(reqBody.canvas_size_id).trim() !== '') {
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
throw createValidationError('Canvas size is invalid.');
}
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
if (!canvasSize) {
throw createValidationError('Canvas size not found.');
}
requestedCanvasId = Number(canvasSize.id);
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
throw new Error('Canvas size cannot be changed after the playlist is created.');
}
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
const slideIds = readArrayField(reqBody, ['slide_id[]', 'slide_id']);
const rowKeys = readRawFieldArray(reqBody, ['row_key[]', 'row_key']);
const durations = readArrayField(reqBody, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(reqBody, ['use_video_duration[]', 'use_video_duration']);
const disableAudios = readArrayField(reqBody, ['disable_audio[]', 'disable_audio']);
const scheduleRules = buildScheduleRulesFromRequestBody(reqBody);
if (durations.length && durations.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (disableAudios.length && disableAudios.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (rowKeys.length && rowKeys.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
const scheduleRulesByRowKey = new Map();
if (scheduleRules === null) {
throw createValidationError('Playlist schedule data is invalid.');
}
scheduleRules.forEach(function (rule) {
const rowKey = String(rule.rowKey || '').trim();
if (!scheduleRulesByRowKey.has(rowKey)) {
scheduleRulesByRowKey.set(rowKey, []);
}
scheduleRulesByRowKey.get(rowKey).push(rule);
});
const normalizedSlides = [];
const seenSlideIds = new Set();
for (let i = 0; i < slideIds.length; i += 1) {
const slideId = Number(slideIds[i]);
if (!Number.isInteger(slideId) || slideId <= 0) {
throw createValidationError('Invalid slide selection.');
}
if (seenSlideIds.has(slideId)) {
throw createValidationError('A slide can only be added to a playlist once.');
}
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const disableAudio = String(disableAudios[i] || '') !== '0' ? 1 : 0;
const rowKey = String(rowKeys[i] || '').trim();
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
for (const rule of playlistScheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
throw createValidationError('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
throw createValidationError('End time must be after start time.');
}
}
normalizedSlides.push({
slideId,
rowKey,
position: i,
durationSeconds,
useVideoDuration,
disableAudio,
scheduleRules: playlistScheduleRules
});
}
let selectedCanvasId = null;
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, st.canvas_size_id AS canvas_size_id
FROM c_slides sl
LEFT JOIN c_templates st ON st.id = sl.template_id
WHERE sl.id IN (?)`,
[normalizedSlides.map(function (item) { return item.slideId; })]
);
if (slides.length !== normalizedSlides.length) {
throw createValidationError('One or more selected slides no longer exist.');
}
const signatures = Array.from(new Set(
slides
.map(function (slide) { return Number(slide.canvas_size_id); })
.filter(function (value) { return Number.isInteger(value) && value > 0; })
));
selectedCanvasId = signatures.length === 1 ? signatures[0] : null;
if (signatures.length > 1) {
throw createValidationError('All playlist slides must share the same canvas size.');
}
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && selectedCanvasId && Number(playlist.canvas_id) !== selectedCanvasId) {
throw createValidationError('All playlist slides must match the playlist canvas size.');
}
}
const nextCanvasId = Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0
? Number(playlist.canvas_id)
: requestedCanvasId || selectedCanvasId || null;
if (shouldUpdatePlaylist) {
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]);
}
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
const [insertResult] = await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, actorId, actorId]
);
const insertedPlaylistSlideId = Number(insertResult.insertId);
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
const rule = itemRules[ruleIndex];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId]
);
}
}
return {
affectedScreens: affectedScreens,
nextCanvasId: nextCanvasId,
selectedCanvasId: selectedCanvasId
};
}
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(req.body.canvas_size_id);
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
return res.status(400).send('Canvas size is required.');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
return res.status(400).send('A playlist with that name already exists.');
}
const canvasSize = await common.fetchCanvasSizeById(pool, canvasSizeId);
if (!canvasSize) {
return res.status(400).send('Canvas size not found.');
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, canvasSize.id, actorId, actorId]);
const playlist = {
id: Number(result.insertId),
canvas_id: Number(canvasSize.id)
};
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
await connection.commit();
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
if (error && error.statusCode === 400) {
return res.status(400).send(error.message || 'Playlist data is invalid.');
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(req.body.canvas_size_id);
const actorId = getAuditUserId(req);
let requestedCanvasId = null;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
if (req.body.canvas_size_id !== undefined && req.body.canvas_size_id !== null && String(req.body.canvas_size_id).trim() !== '') {
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
return res.status(400).send('Canvas size is invalid.');
}
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
if (!canvasSize) {
return res.status(400).send('Canvas size not found.');
}
requestedCanvasId = Number(canvasSize.id);
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
return res.status(400).send('Canvas size cannot be changed after the playlist is created.');
}
}
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
if (error && error.statusCode === 400) {
return res.status(400).send(error.message || 'Playlist data is invalid.');
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
if (blockMessage) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const slideId = Number(req.body.slide_id);
if (!slideId) {
return res.status(400).send('Slide is required.');
}
const playlistCanvasId = await fetchPlaylistCanvasId(pool, playlist.id);
if (playlistCanvasId === 'mismatch') {
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
return res.status(404).send('Slide not found');
}
const slideCanvasId = Number(slide.canvas_size_id);
if (playlistCanvasId && slideCanvasId !== playlistCanvasId) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, 1, actorId, actorId]);
if (!Number.isInteger(Number(playlist.canvas_id)) || Number(playlist.canvas_id) <= 0) {
await pool.query('UPDATE c_playlists SET canvas_id = ?, modified_by = ? WHERE id = ?', [slideCanvasId, actorId, playlist.id]);
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [currentRows] = await pool.query('SELECT disable_audio FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [playlistSlideId, playlist.id]);
if (!currentRows.length) {
return res.status(404).send('Playlist slide not found');
}
const disableAudio = req.body.disable_audio === undefined ? (Number(currentRows[0].disable_audio) ? 1 : 0) : (String(req.body.disable_audio || '') === '0' ? 0 : 1);
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET duration_seconds = ?, disable_audio = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, disableAudio, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide config updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const direction = String(req.body.direction || '').toLowerCase();
if (direction !== 'up' && direction !== 'down') {
return res.status(400).send('Invalid move direction.');
}
await connection.beginTransaction();
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
const currentIndex = orderedSlides.findIndex(function (item) {
return Number(item.id) === playlistSlideId;
});
if (currentIndex === -1) {
await connection.rollback();
return res.status(404).send('Playlist slide not found');
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
app.get('/playlists/new/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
return String(key || '').indexOf('schedule_rule_') === 0;
});
const scheduleRules = hasQueryScheduleRules
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
let days = [];
try {
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
} catch (_error) {
days = [];
}
return {
start_datetime: rule.startDatetime,
end_datetime: rule.endDatetime,
start_time: rule.startTime,
end_time: rule.endTime,
days: Array.isArray(days) ? days : []
};
})
: [];
return res.send(pages.renderPlaylistSlideConfigPage({ id: null }, {
id: 0,
scheduleRules: scheduleRules
}, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlistId = Number(req.params.id);
if (!Number.isInteger(playlistId) || playlistId <= 0) {
return res.status(404).send('Playlist not found');
}
const playlist = await common.fetchPlaylistById(pool, playlistId);
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
let playlistSlide = (data.playlistSlides || []).find(function (item) {
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
});
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
return res.status(404).send('Playlist slide not found');
}
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
return String(key || '').indexOf('schedule_rule_') === 0;
});
const scheduleRules = hasQueryScheduleRules
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
let days = [];
try {
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
} catch (_error) {
days = [];
}
return {
start_datetime: rule.startDatetime,
end_datetime: rule.endDatetime,
start_time: rule.startTime,
end_time: rule.endTime,
days: Array.isArray(days) ? days : []
};
})
: (Array.isArray(playlistSlide && playlistSlide.scheduleRules) ? playlistSlide.scheduleRules : []);
playlistSlide = Object.assign({}, playlistSlide || {}, {
id: playlistSlide ? playlistSlide.id : 0,
scheduleRules: scheduleRules
});
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const actorId = getAuditUserId(req);
const scheduleRules = buildScheduleRulesFromRequestBody(req.body);
if (scheduleRules === null) {
return res.status(400).send('Schedule rules are invalid.');
}
for (const rule of scheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
return res.status(400).send('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
return res.status(400).send('End time must be after start time.');
}
}
const [result] = await pool.query(
'UPDATE c_playlist_slides SET modified_by = ? WHERE id = ? AND playlist_id = ?',
[actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await replacePlaylistSlideScheduleRules(pool, playlistSlideId, scheduleRules, actorId);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
};
+38 -8
View File
@@ -129,14 +129,19 @@
return groups;
}
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId) {
const permissionRows = await rbacData.fetchPermissions(pool);
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, {
excludeUserId: currentUserId
});
return {
formValues: {
name: String(formValues && formValues.name || '').trim(),
description: String(formValues && formValues.description || '').trim()
},
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(data.users, selectedUserIds),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')
};
}
@@ -177,11 +182,16 @@
});
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
buildRoleCreateViewModel({
name: String(req.query.name || '').trim(),
description: String(req.query.description || '').trim()
}, []).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
}, [], [], page, search, sort, direction, currentUserId).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
}).catch(function (error) {
next(error);
});
@@ -191,6 +201,10 @@
try {
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
@@ -199,18 +213,33 @@
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
if (await common.fetchDuplicateName(pool, 'a_roles', name)) {
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
if (selectedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const availableUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (normalizedUserIds.some(function (userId) {
return !availableUserIds.has(userId);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected users are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
const roleKey = await createUniqueRoleKey(name);
@@ -227,6 +256,7 @@
if (selectedPermissionKeys.length) {
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
}
await rbacData.syncRoleUsers(connection, insertedRoleId, normalizedUserIds);
await connection.commit();
} catch (error) {
await connection.rollback();
+61 -14
View File
@@ -123,7 +123,7 @@
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
const username = String(req.body.username || user.username || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
@@ -171,7 +171,7 @@
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
res.redirect('/users?message=' + encodeURIComponent('User created.'));
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
} catch (error) {
try {
await connection.rollback();
@@ -213,46 +213,93 @@
});
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const userId = Number(req.params.id);
const name = String(req.body.name || '').trim();
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const shouldUpdatePassword = Boolean(password || confirmPassword);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (!name) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [userRows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!userRows.length) {
const user = await rbacData.fetchUserWithRoles(pool, userId);
if (!user) {
return res.status(404).send('User not found.');
}
const username = String(req.body.username || userRows[0].username || '').trim();
if (!username) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
const username = String(req.body.username || user.username || '').trim();
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
const roleCheck = await validateRoleIds(selectedRoleIds);
async function renderValidationError(message) {
const roles = await fetchRoleOptions();
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
name: name || user.name,
username: username || user.username,
roleIds: selectedRoleIds,
inUse: !canDelete
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
}
if (!name) {
return renderValidationError('Name is required.');
}
if (!username) {
return renderValidationError('Username is required.');
}
if (shouldUpdatePassword && (!password || password.length < 8)) {
return renderValidationError('Password must be at least 8 characters.');
}
if (shouldUpdatePassword && password !== confirmPassword) {
return renderValidationError('Passwords do not match.');
}
if (!roleCheck.ok) {
return renderValidationError(roleCheck.message);
}
if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
return renderValidationError('That name already exists.');
}
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
if (existingRows.length) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
return renderValidationError('That username already exists.');
}
const [result] = await pool.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
await connection.beginTransaction();
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
await connection.rollback();
return res.status(404).send('User not found.');
}
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
if (shouldUpdatePassword) {
const passwordRecord = hashPassword(password);
await connection.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
await connection.commit();
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});