// 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 forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; const getScreenConnections = deps.getScreenConnections; const isClientNameAvailable = deps.isClientNameAvailable; const withClientNameReservation = deps.withClientNameReservation; const broadcastDashboardState = deps.broadcastDashboardState; const requirePermission = deps.requirePermission; const ALL_SCREENS_SLUG = '__all__'; async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) { if (typeof getScreenConnections !== 'function' || !screenSlug) { return []; } try { const response = await getScreenConnections(screenSlug); const liveConnections = Array.isArray(response && response.connections) ? response.connections : []; if (!liveConnections.length) { return []; } const normalizedConnectionId = String(connectionId || '').trim(); const liveConnection = normalizedConnectionId ? liveConnections.find(function (connection) { const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim(); const candidateDeviceId = String(connection && connection.deviceId || '').trim(); return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId; }) : null; const targetConnections = liveConnection ? [liveConnection] : liveConnections; return Array.from(new Set(targetConnections.map(function (connection) { return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, ''); }).filter(Boolean))); } catch (_error) { return []; } } function normalizeExplicitPlayerBaseUrl(value) { return String(value || '').trim().replace(/\/$/, ''); } function isRecentPlayerRegistration(player, staleSeconds) { const lastSeenAt = player && player.last_seen_at; const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; } async function resolveAllPlayerBaseUrls() { if (!common || typeof common.fetchPlayerRegistrations !== 'function') { return []; } try { const players = await common.fetchPlayerRegistrations(pool); return Array.from(new Set((Array.isArray(players) ? players : []) .filter(function (player) { return isRecentPlayerRegistration(player, 60); }) .map(function (player) { return String(player && player.public_base_url || '').trim().replace(/\/$/, ''); }) .filter(Boolean))); } catch (_error) { return []; } } app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) { try { const slug = String(req.params.slug || '').trim(); const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); const explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || ''); const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') ? req.body.blackout : req.query.blackout; if (!slug) { return res.status(400).json({ error: 'Screen slug is required' }); } if (!command) { return res.status(400).json({ error: 'Command is required' }); } if (slug === ALL_SCREENS_SLUG) { if (command === 'setclientname' || command === 'moveclient') { return res.status(400).json({ error: 'This command requires a specific screen.' }); } const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC'); if (!screenRows.length) { return res.status(404).json({ error: 'No screens found' }); } const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? Object.assign({}, req.body, { command: command }) : { command: command }; if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { commandPayload.blackout = blackoutValue; } await Promise.all(screenRows.map(function (screenRow) { const screenSlug = String(screenRow && screenRow.slug || '').trim(); return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) { if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { return Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined); })); } return forwardPlayerCommand(screenSlug, commandPayload); }); })); if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); } return res.json({ screen: { id: null, name: 'All screens', slug: ALL_SCREENS_SLUG }, screenSlug: slug, command: command, connectionId: connectionId || null, targetScreenCount: screenRows.length, ok: true, allScreens: true }); } const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]); if (!screenRows.length) { return res.status(404).json({ error: 'Screen not found' }); } if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') { const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? Object.assign({}, req.body, { command: command }) : { command: command }; if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { commandPayload.blackout = blackoutValue; } const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined); if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); } return res.json(Object.assign({ screen: screenRows[0], screenSlug: slug, command: command, connectionId: connectionId || null }, result && typeof result === 'object' ? result : {})); } if (command === 'setclientname') { 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(); if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } if (!clientName) { return res.status(400).json({ error: 'Client name is required' }); } const [currentRows] = await pool.query( `SELECT client_name FROM d_onboarding_devices WHERE device_id = ? LIMIT 1`, [deviceId] ); const onboardingRow = currentRows[0] || null; const currentName = String(onboardingRow && onboardingRow.client_name ? onboardingRow.client_name : '').trim(); if (currentName && currentName.toLowerCase() === clientName.toLowerCase()) { return res.json({ screen: screenRows[0], screenSlug: slug, command: command, connectionId: connectionId || null, deviceId: deviceId, clientName: currentName, ok: true, unchanged: true }); } if (typeof withClientNameReservation !== 'function') { return res.status(500).json({ error: 'Client name reservation is unavailable.' }); } return withClientNameReservation(pool, clientName, async function () { 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 available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections); if (!available) { return res.status(409).json({ error: 'Client name already exists.' }); } if (!onboardingRow) { await forwardPlayerCommand(slug, { command: command, clientName: clientName, clientId: connectionId || deviceId || null, deviceId: deviceId || null }, 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: clientName, ok: true, liveOnly: true }); } 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] ); if (!updateResult.affectedRows) { return res.status(404).json({ error: 'Client not found' }); } return res.json({ screen: screenRows[0], screenSlug: slug, command: command, connectionId: connectionId || null, deviceId: deviceId, clientName: clientName, ok: true }); }); } 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 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; }) : null; const sourcePlayerBaseUrl = String( explicitPlayerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || (typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') || '' ).trim().replace(/\/$/, ''); const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`; if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') { await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, { command: 'redirect', url: targetPlayerUrl }, connectionId || deviceId || undefined); } else { 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 }; if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { commandPayload.blackout = blackoutValue; } const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId); const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function' ? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId); })).then(function (results) { return Array.isArray(results) && results.length ? results[0] : { ok: true }; }) : (connectionId ? await forwardPlayerCommand(slug, commandPayload, connectionId) : await forwardPlayerCommand(slug, commandPayload)); if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); } return res.json(Object.assign({ screen: screenRows[0], screenSlug: slug, command: command, connectionId: connectionId || null }, result && typeof result === 'object' ? result : {})); } catch (error) { next(error); } }); };