Add onboarding weather and template gradients
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
2026-08-28 20:05:23 +01:00
parent aa07a78912
commit 3960931ebe
80 changed files with 12750 additions and 519 deletions
+128 -29
View File
@@ -1,6 +1,6 @@
// Admin client command routes for connected screens.
const { commitDeviceBinding } = require('#src/player/onboarding');
const { createPageAuthToken } = require('#src/request-auth');
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
@@ -227,7 +227,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
if (command === 'setclientname') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const physicalDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
const clientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim();
const deviceId = physicalDeviceId || clientId || String((req.body && req.body.connectionId) || req.query.connectionId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
@@ -236,13 +238,26 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Client name is required' });
}
const [currentRows] = await pool.query(
let bindingDeviceId = deviceId;
let [currentRows] = await pool.query(
`SELECT client_name
FROM d_onboarding_devices
WHERE device_id = ?
LIMIT 1`,
[deviceId]
[clientId || deviceId]
);
if (!currentRows.length && clientId && physicalDeviceId) {
bindingDeviceId = physicalDeviceId;
[currentRows] = await pool.query(
`SELECT client_name
FROM d_onboarding_devices
WHERE device_id = ?
LIMIT 1`,
[physicalDeviceId]
);
} else if (clientId) {
bindingDeviceId = clientId;
}
const onboardingRow = currentRows[0] || null;
const currentName = String(onboardingRow && onboardingRow.client_name ? onboardingRow.client_name : '').trim();
if (currentName && currentName.toLowerCase() === clientName.toLowerCase()) {
@@ -282,7 +297,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
liveConnections = [];
}
const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections);
const available = await isClientNameAvailable(pool, clientName, bindingDeviceId, liveConnections);
if (!available) {
return res.status(409).json({ error: 'Client name already exists.' });
}
@@ -321,11 +336,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
const [updateResult] = await pool.query(
`UPDATE d_onboarding_devices pod
JOIN d_screens s ON s.id = pod.screen_id
SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP
WHERE s.slug = ? AND pod.device_id = ?`,
[clientName, slug, deviceId]
`UPDATE d_onboarding_devices
SET client_name = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[clientName, bindingDeviceId]
);
if (!updateResult.affectedRows) {
return res.status(404).json({ error: 'Client not found' });
@@ -344,24 +358,43 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
if (command === 'moveclient') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const legacyDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
let physicalPlayerId = '';
const tabClientId = String((req.body && req.body.clientId) || 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();
const submittedPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId) {
return res.status(400).json({ error: 'Client connection is required' });
}
if (!targetScreenSlug) {
return res.status(400).json({ error: 'Target screen is required' });
}
if (submittedPlayerBaseUrl) {
const [playerRows] = await pool.query(
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
[submittedPlayerBaseUrl]
);
const registeredPlayerId = String(playerRows[0] && playerRows[0].identifier || '').trim();
if (registeredPlayerId) {
physicalPlayerId = registeredPlayerId;
}
}
physicalPlayerId = physicalPlayerId || legacyDeviceId;
if (!physicalPlayerId) {
return res.status(400).json({ error: 'Registered player identity 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]
[physicalPlayerId]
);
const onboardingRow = currentRows[0] || null;
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
@@ -371,13 +404,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Client name is required' });
}
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
if (!connectionId && currentScreenSlug && currentScreenSlug === targetScreenSlug) {
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
deviceId: physicalPlayerId,
clientName: resolvedClientName,
targetScreenSlug: targetScreenSlug,
ok: true,
@@ -385,19 +418,78 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
});
}
if (typeof commitDeviceBinding !== 'function') {
return res.status(500).json({ error: 'Client binding is unavailable.' });
}
const liveConnections = await fetchScreenConnections(slug);
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
let status = null;
if (onboardingRow) {
const [targetRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ? LIMIT 1', [targetScreenSlug]);
if (!targetRows.length) {
return res.status(404).json({ error: 'Target screen not found' });
}
if (tabClientId) {
const [tabUpdateResult] = await pool.query(
`UPDATE d_onboarding_devices
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[resolvedClientName, targetRows[0].id, tabClientId]
);
if (!tabUpdateResult.affectedRows) {
await pool.query(
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
VALUES (?, ?, ?)`,
[tabClientId, resolvedClientName, targetRows[0].id]
);
}
} else {
await pool.query(
`UPDATE d_onboarding_devices
SET screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[targetRows[0].id, physicalPlayerId]
);
}
const stablePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
if (stablePlayerBaseUrl && !tabClientId) {
const [playerRows] = await pool.query(
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
[stablePlayerBaseUrl]
);
const stablePlayer = playerRows[0] || null;
if (stablePlayer && stablePlayer.identifier) {
await pool.query(
`UPDATE d_onboarding_devices
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[resolvedClientName, targetRows[0].id, stablePlayer.identifier]
);
await pool.query(
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
SELECT ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
)`,
[stablePlayer.identifier, resolvedClientName, targetRows[0].id, stablePlayer.identifier]
);
}
}
status = {
client_name: resolvedClientName,
screen_id: targetRows[0].id,
screen_slug: targetScreenSlug
};
} else {
status = {
client_name: resolvedClientName,
screen_id: null,
screen_slug: targetScreenSlug,
live_only: true
};
}
let targetPlayerUrl = '';
const liveConnection = Array.isArray(liveConnections)
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
return candidateConnectionId === connectionId || candidateConnectionId === physicalPlayerId || candidateDeviceId === physicalPlayerId;
}) || liveConnections[0] || null
: null;
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
@@ -407,17 +499,24 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
if (!targetPlayerUrl) {
targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`;
}
const moveToken = createPageAuthToken({
scope: 'screen-move',
playerId: physicalPlayerId,
connectionId: connectionId || null,
screenSlug: targetScreenSlug
});
if (playerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined, deviceId || null);
url: targetPlayerUrl,
moveToken: moveToken || null
}, connectionId || physicalPlayerId || undefined, physicalPlayerId || null);
} else {
await forwardPlayerCommandForConnections(slug, liveConnections, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined, deviceId || null);
url: targetPlayerUrl,
moveToken: moveToken || null
}, connectionId || physicalPlayerId || undefined, physicalPlayerId || null);
}
if (typeof broadcastDashboardState === 'function') {
@@ -429,7 +528,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
deviceId: physicalPlayerId,
clientName: status ? status.client_name : resolvedClientName,
targetScreenSlug: targetScreenSlug,
playerUrl: targetPlayerUrl,
+4 -4
View File
@@ -632,8 +632,8 @@ module.exports = function registerContentRoutes(app, deps) {
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, actorId]
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, background_gradient, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, actorId]
);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
@@ -707,8 +707,8 @@ module.exports = function registerContentRoutes(app, deps) {
regions: normalizeTemplateRegionsForAudit(payload.regions)
});
await pool.query(
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, background_gradient = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, template.id]
);
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
for (let i = 0; i < payload.regions.length; i += 1) {
+21
View File
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../lib/pagination');
const renderWeatherLocationsPage = require('./weather/list');
const renderWeatherLocationAddPage = require('./weather/add');
const renderWeatherLocationEditPage = require('./weather/edit');
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('./weather/duplicate');
const { fetchAppSettings } = require('../../../data/app-settings');
async function getWeatherLocationUsageIds(pool, common) {
@@ -85,6 +86,26 @@ module.exports = function registerWeatherRoutes(app, deps) {
} catch (error) { next(error); }
});
app.get('/data-sources/weather/:id/duplicate', requirePermission('weather.read'), requirePermission('weather.create'), async function (req, res, next) {
try {
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
if (!location) return res.status(404).send('Weather location not found');
let duplicateName = buildDuplicateWeatherLocationName(location.name);
let duplicateIndex = 2;
while (await common.fetchDuplicateName(pool, 'i_weather_locations', duplicateName)) {
duplicateName = buildDuplicateWeatherLocationName(location.name) + ' (' + duplicateIndex + ')';
duplicateIndex += 1;
}
res.send(renderWeatherLocationAddPage(buildDuplicateWeatherLocation(location, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
providerAvailability: await getProviderAvailability(),
messageVariant: 'info',
showSaveSecondaryActions: true
}));
} catch (error) { next(error); }
});
app.post('/data-sources/weather', requirePermission('weather.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
@@ -0,0 +1,22 @@
// Weather location duplication helpers.
function buildDuplicateWeatherLocationName(locationName) {
return 'Copy of ' + String(locationName || '').trim();
}
function buildDuplicateWeatherLocation(location, duplicateName) {
return Object.assign({}, location, {
id: null,
name: duplicateName,
last_pulled_at: null,
last_pull_error: '',
last_response_status: null,
last_response_content_type: '',
last_response_json: ''
});
}
module.exports = {
buildDuplicateWeatherLocationName,
buildDuplicateWeatherLocation
};
+98
View File
@@ -0,0 +1,98 @@
const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { renderView } = require('../view');
module.exports = function registerOnboardingRoutes(app, deps) {
const pool = deps.pool;
const playerInternalBaseUrl = String(deps.playerInternalBaseUrl || '').replace(/\/$/, '');
const requirePermission = deps.requirePermission;
app.get('/pairing', requirePermission('pairing.allow'), async function (req, res, next) {
try {
const [screens] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.send(renderView('onboarding/pair', {
title: 'Pair player',
active: 'pairing',
currentUser: req.currentUser,
clientId: String(req.query.clientId || '').trim(),
pairingCode: String(req.query.code || '').trim(),
screens: screens,
scripts: ['vendor/jsqr/jsQR.js', 'js/onboarding/pair.js?v=' + encodeURIComponent(require('#root/package.json').version)]
}));
} catch (error) {
next(error);
}
});
app.post('/pairing', requirePermission('pairing.allow'), async function (req, res) {
const pairingCode = String(req.body && req.body.pairingCode || '').trim();
const clientId = String(req.body && req.body.clientId || '').trim();
const clientName = String(req.body && req.body.clientName || '').trim();
const screenSlug = String(req.body && req.body.screenSlug || '').trim();
if (!pairingCode || !clientName || !screenSlug) {
return res.status(400).json({ error: 'All pairing fields are required.' });
}
try {
let targetBaseUrl = playerInternalBaseUrl;
let deviceId = '';
let resolvedClientId = clientId;
{
const resolvePath = '/api/onboarding/resolve?pairingCode=' + encodeURIComponent(pairingCode);
const resolveAuthHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders)
});
const resolveBody = await resolveResponse.text();
let resolved = null;
try { resolved = JSON.parse(resolveBody); } catch (_error) {}
if (!resolveResponse.ok || !resolved || !resolved.deviceId) {
const registrations = await fetchPlayerRegistrations(pool);
const remoteRegistrations = registrations.filter(function (player) {
const internalUrl = String(player && player.internal_base_url || '').replace(/\/$/, '');
return internalUrl && internalUrl !== playerInternalBaseUrl;
});
if (!remoteRegistrations.length) {
return res.status(resolveResponse.status || 401).type(resolveResponse.headers.get('content-type') || 'application/json').send(resolveBody || JSON.stringify({ error: 'Unable to resolve kiosk pairing code.' }));
}
for (const remoteRegistration of remoteRegistrations) {
targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, '');
const bridgeResolveHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders)
});
const bridgeResolveBody = await bridgeResolveResponse.text();
try { resolved = JSON.parse(bridgeResolveBody); } catch (_error) { resolved = null; }
if (bridgeResolveResponse.ok && resolved && resolved.deviceId) {
break;
}
}
if (!resolved || !resolved.deviceId) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
}
if (resolved && resolved.deviceId) {
deviceId = String(resolved.deviceId).trim();
resolvedClientId = String(resolved.clientId || resolvedClientId || '').trim();
}
}
if (!resolvedClientId) {
return res.status(400).json({ error: 'Client ID is required.' });
}
const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug };
const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/onboarding', body: payload });
const response = await fetch(`${targetBaseUrl}/api/onboarding`, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders),
body: JSON.stringify(payload)
});
const responseBody = await response.text();
res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(responseBody);
} catch (error) {
res.status(502).json({ error: error && error.message ? error.message : 'Player unavailable.' });
}
});
};
+6
View File
@@ -21,11 +21,17 @@ const registerAuditLogRoutes = require('./settings/audit-log');
const registerAboutRoutes = require('./settings/about/routes');
const registerInternalSyncRoutes = require('./internal/sync');
const registerScreensRoutes = require('./signage/screens/routes');
const registerOnboardingRoutes = require('./onboarding');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
function registerRoutes(app, deps) {
// Group routes by the dependency bundle they need.
registerAuthAndAccountRoutes(app, deps);
registerOnboardingRoutes(app, {
pool: deps.pool,
playerInternalBaseUrl: deps.playerInternalBaseUrl,
requirePermission: deps.requirePermission
});
registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps);
registerSettingsPageRoutes(app, {
+18 -1
View File
@@ -32,6 +32,21 @@ module.exports = function registerClientsRoutes(app, deps) {
return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue));
}
function compareClientNames(leftValue, rightValue) {
const leftName = String(leftValue || '').trim();
const rightName = String(rightValue || '').trim();
if (!leftName && !rightName) {
return 0;
}
if (!leftName) {
return 1;
}
if (!rightName) {
return -1;
}
return leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
}
const sortKeys = accessors[normalizedSortKey]
? [normalizedSortKey]
: ['client'];
@@ -39,7 +54,9 @@ module.exports = function registerClientsRoutes(app, deps) {
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (let index = 0; index < sortKeys.length; index += 1) {
const sortKeyName = sortKeys[index];
const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
const comparison = sortKeyName === 'client'
? compareClientNames(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient))
: compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
if (comparison !== 0) {
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
-11
View File
@@ -2,17 +2,7 @@
const { renderView } = require('../../../view');
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
module.exports = function renderDashboardPage(data, message, currentUser) {
const primaryPlayerUrl = Array.isArray(data.screens)
? String((data.screens.find(function (screen) {
return screen && String(screen.public_base_url || '').trim();
}) || {}).public_base_url || '').trim()
: '';
return renderView('dashboard/index', {
title: 'Dashboard',
active: 'dashboard',
@@ -25,7 +15,6 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
slides: data.slides || [],
connectedClientsCount: Number(data.connectedClientsCount || 0),
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
scripts: ['js/dashboard/dashboard-page.js']
});
};
@@ -26,6 +26,7 @@ function buildDuplicateTemplate(template, duplicateName) {
canvas_size_height: source.canvas_size_height,
background_color: source.background_color,
background_image_path: source.background_image_path,
background_gradient: source.background_gradient,
region_usage: [],
regions: Array.isArray(source.regions) ? source.regions.map(cloneRegion) : []
};