diff --git a/package.json b/package.json index 91e6f8d..83def6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.4.0", + "version": "1.3.1", "private": false, "description": "Pulse Signage application with MySQL and media uploads", "repository": { diff --git a/src/client-name-check.js b/src/client-name-check.js new file mode 100644 index 0000000..935c64f --- /dev/null +++ b/src/client-name-check.js @@ -0,0 +1,76 @@ +function normalizeClientName(value) { + return String(value || '').trim(); +} + +function normalizeDeviceId(value) { + return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); +} + +function collectLiveConnections(liveConnections) { + return Array.isArray(liveConnections) ? liveConnections : []; +} + +async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConnections) { + const normalizedName = normalizeClientName(clientName); + if (!normalizedName) { + return false; + } + + const normalizedDeviceId = normalizeDeviceId(excludeDeviceId); + const live = collectLiveConnections(liveConnections); + const lowerName = normalizedName.toLowerCase(); + + try { + if (pool) { + const [deviceRows] = await pool.query( + `SELECT device_id + FROM player_onboarding_devices + WHERE client_name IS NOT NULL + AND TRIM(client_name) <> '' + AND LOWER(TRIM(client_name)) = LOWER(TRIM(?)) + AND device_id <> ? + LIMIT 1`, + [normalizedName, normalizedDeviceId] + ); + + if (deviceRows.length) { + return false; + } + } + + for (const connection of live) { + const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : ''); + if (!existingName || existingName.toLowerCase() !== lowerName) { + continue; + } + const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : ''); + if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) { + continue; + } + return false; + } + + return true; + } catch (_error) { + for (const connection of live) { + const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : ''); + if (!existingName || existingName.toLowerCase() !== lowerName) { + continue; + } + const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : ''); + if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) { + continue; + } + return false; + } + + return true; + } +} + +module.exports = { + normalizeClientName: normalizeClientName, + normalizeDeviceId: normalizeDeviceId, + collectLiveConnections: collectLiveConnections, + isClientNameAvailable: isClientNameAvailable +}; \ No newline at end of file diff --git a/src/db.js b/src/db.js index 641aa4c..bd34b5e 100644 --- a/src/db.js +++ b/src/db.js @@ -31,6 +31,13 @@ async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`); } +async function pruneStaleOnboardingDevices(pool) { + await pool.query( + `DELETE FROM player_onboarding_devices + WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)` + ); +} + async function ensureSchema(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS canvas_sizes ( @@ -262,5 +269,6 @@ async function ensureSchema(pool) { module.exports = { createPool, - ensureSchema + ensureSchema, + pruneStaleOnboardingDevices }; diff --git a/src/player.js b/src/player.js index 835d481..523a43f 100644 --- a/src/player.js +++ b/src/player.js @@ -5,8 +5,10 @@ const path = require('path'); const common = require('./common'); const { createPlayerRuntime } = require('./player/runtime'); const { createPlayerPlaylistService } = require('./player/playlist'); -const { normalizeDeviceId, registerPlayerOnboardingRoutes } = require('./player/onboarding'); +const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding'); +const { createOnboardingStore } = require('./player/onboarding-store'); const { registerPlayerRoutes } = require('./player/routes'); +const { pruneStaleOnboardingDevices } = require('./db'); // Player runtime, upload API, and websocket wiring. @@ -16,6 +18,9 @@ async function start() { const PORT = Number(process.env.PLAYER_PORT || 3001); const ASSET_DIR = path.join(__dirname, 'player', 'public'); const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); + const ONBOARDING_QUEUE_FILE = path.join(UPLOAD_DIR, 'player-onboarding-queue.json'); + const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000); + const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE); const playerRuntime = createPlayerRuntime({ pool: pool, normalizeDeviceId: normalizeDeviceId @@ -31,6 +36,7 @@ async function start() { pool: pool, common: common, playerRuntime: playerRuntime, + onboardingStore: onboardingStore, QRCode: require('qrcode') }); registerPlayerRoutes(app, { @@ -47,12 +53,41 @@ async function start() { res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error'); }); - await common.ensureSchema(pool); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); }); + + async function syncDatabaseState() { + try { + await common.ensureSchema(pool); + + if (playerRuntime.snapshotAllConnections().length > 0) { + await pruneStaleOnboardingDevices(pool); + } + + await onboardingStore.flushBindings(function (entry) { + return commitDeviceBinding( + pool, + entry.deviceId, + entry.clientName, + entry.screenSlug, + playerRuntime.isClientNameAvailableOnScreen, + playerRuntime.snapshotAllConnections() + ); + }); + } catch (error) { + console.error(error); + } + } + + await syncDatabaseState(); + setInterval(function () { + syncDatabaseState().catch(function (error) { + console.error(error); + }); + }, DB_SYNC_INTERVAL_MS); } module.exports = { start }; diff --git a/src/player/onboarding-store.js b/src/player/onboarding-store.js new file mode 100644 index 0000000..2fcd07e --- /dev/null +++ b/src/player/onboarding-store.js @@ -0,0 +1,98 @@ +const fs = require('fs'); +const path = require('path'); + +function isTransientDbError(error) { + const code = String(error && error.code ? error.code : '').trim(); + return [ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'ENOTFOUND', + 'PROTOCOL_CONNECTION_LOST', + 'POOL_CLOSED', + 'ERR_POOL_CLOSED' + ].indexOf(code) !== -1; +} + +function createOnboardingStore(filePath) { + const normalizedFilePath = String(filePath || '').trim(); + + async function readEntries() { + try { + const raw = await fs.promises.readFile(normalizedFilePath, 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + if (error && error.code === 'ENOENT') { + return []; + } + throw error; + } + } + + async function writeEntries(entries) { + await fs.promises.mkdir(path.dirname(normalizedFilePath), { recursive: true }); + const tempPath = `${normalizedFilePath}.tmp`; + await fs.promises.writeFile(tempPath, JSON.stringify(Array.isArray(entries) ? entries : [], null, 2), 'utf8'); + await fs.promises.rename(tempPath, normalizedFilePath); + } + + async function enqueueBinding(entry) { + const normalizedEntry = { + deviceId: String(entry && entry.deviceId ? entry.deviceId : '').trim(), + clientName: String(entry && entry.clientName ? entry.clientName : '').trim(), + screenSlug: String(entry && entry.screenSlug ? entry.screenSlug : '').trim(), + queuedAt: String(entry && entry.queuedAt ? entry.queuedAt : new Date().toISOString()) + }; + + if (!normalizedEntry.deviceId || !normalizedEntry.clientName || !normalizedEntry.screenSlug) { + return readEntries(); + } + + const entries = await readEntries(); + const nextEntries = entries.filter(function (queuedEntry) { + return String(queuedEntry && queuedEntry.deviceId ? queuedEntry.deviceId : '').trim() !== normalizedEntry.deviceId; + }); + nextEntries.push(normalizedEntry); + await writeEntries(nextEntries); + return nextEntries; + } + + async function flushBindings(applyBinding) { + const entries = await readEntries(); + if (!entries.length) { + return { flushed: 0, remaining: 0 }; + } + + const remaining = []; + let flushed = 0; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + try { + await applyBinding(entry); + flushed += 1; + } catch (error) { + if (isTransientDbError(error)) { + remaining.push.apply(remaining, entries.slice(index)); + break; + } + remaining.push.apply(remaining, entries.slice(index + 1)); + } + } + + await writeEntries(remaining); + return { flushed: flushed, remaining: remaining.length }; + } + + return { + enqueueBinding: enqueueBinding, + flushBindings: flushBindings, + readEntries: readEntries + }; +} + +module.exports = { + createOnboardingStore: createOnboardingStore, + isTransientDbError: isTransientDbError +}; \ No newline at end of file diff --git a/src/player/onboarding.js b/src/player/onboarding.js index b8594d7..928e28c 100644 --- a/src/player/onboarding.js +++ b/src/player/onboarding.js @@ -1,3 +1,5 @@ +const { isClientNameAvailable } = require('../client-name-check'); +const { isTransientDbError } = require('./onboarding-store'); function normalizeDeviceId(value) { return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); } @@ -31,7 +33,7 @@ async function getOnboardingStatus(pool, deviceId) { return rows[0] || null; } -async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen) { +async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) { const normalizedDeviceId = normalizeDeviceId(deviceId); const normalizedClientName = String(clientName || '').trim(); const normalizedScreenSlug = String(screenSlug || '').trim(); @@ -52,8 +54,8 @@ async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isName } const screen = screenRows[0]; - const available = typeof isNameAvailableOnScreen === 'function' - ? await isNameAvailableOnScreen(pool, normalizedClientName, normalizedDeviceId) + const available = typeof isClientNameAvailableOnScreen === 'function' + ? await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections) : true; if (!available) { const error = new Error('Client name already exists.'); @@ -69,11 +71,42 @@ async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isName return getOnboardingStatus(pool, normalizedDeviceId); } +async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) { + const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function' + ? playerRuntime.snapshotAllConnections() + : []; + + try { + return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections); + } catch (error) { + if (!isTransientDbError(error)) { + throw error; + } + + if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') { + await onboardingStore.enqueueBinding({ + deviceId: deviceId, + clientName: clientName, + screenSlug: screenSlug, + queuedAt: new Date().toISOString() + }); + } + + return { + device_id: normalizeDeviceId(deviceId), + client_name: String(clientName || '').trim(), + screen_slug: String(screenSlug || '').trim(), + queued: true + }; + } +} + function registerPlayerOnboardingRoutes(app, options) { const pool = options && options.pool ? options.pool : null; const common = options && options.common ? options.common : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const QRCode = options && options.QRCode ? options.QRCode : null; + const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null; if (!app || !pool || !common || !playerRuntime || !QRCode) { throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.'); @@ -146,14 +179,15 @@ function registerPlayerOnboardingRoutes(app, options) { return res.status(400).json({ error: 'Screen is required' }); } - const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen); + const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore); res.json({ deviceId: deviceId, clientName: status ? status.client_name : clientName, - screenId: status ? status.screen_id : null, - screenSlug: status ? status.screen_slug : null, - screenName: status ? status.screen_name : null, - playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null + screenId: status && status.screen_id ? status.screen_id : null, + screenSlug: status ? status.screen_slug : screenSlug, + screenName: status && status.screen_name ? status.screen_name : null, + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`, + queued: Boolean(status && status.queued) }); } catch (error) { next(error); @@ -165,6 +199,7 @@ module.exports = { normalizeDeviceId: normalizeDeviceId, getPublicBaseUrl: getPublicBaseUrl, getOnboardingStatus: getOnboardingStatus, + commitDeviceBinding: commitDeviceBinding, bindDeviceToScreen: bindDeviceToScreen, registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes }; \ No newline at end of file diff --git a/src/player/routes.js b/src/player/routes.js index 742bde2..278f6c3 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -138,8 +138,8 @@ function registerPlayerRoutes(app, options) { } const sent = connectionId - ? playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload) - : playerRuntime.broadcastCommand(req.params.slug, commandPayload); + ? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload) + : await playerRuntime.broadcastCommand(req.params.slug, commandPayload); res.json({ screen: screenRows[0] || null, diff --git a/src/player/runtime.js b/src/player/runtime.js index 4bec3bf..4fa09e7 100644 --- a/src/player/runtime.js +++ b/src/player/runtime.js @@ -1,5 +1,6 @@ const crypto = require('crypto'); const { WebSocketServer, WebSocket } = require('ws'); +const { isClientNameAvailable } = require('../client-name-check'); function createPlayerRuntime(options) { const pool = options && options.pool ? options.pool : null; @@ -134,76 +135,25 @@ function createPlayerRuntime(options) { }); } - async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) { - const normalizedName = String(clientName || '').trim(); - if (!normalizedName) { - return false; - } - - const normalizedDeviceId = normalizeDeviceId(excludeDeviceId); - const lowerName = normalizedName.toLowerCase(); - const liveDeviceIds = new Set(); - const liveClientIds = new Set(); - + function snapshotAllConnections() { + const allConnections = []; for (const bucket of connectionsBySlug.values()) { if (!bucket || typeof bucket.values !== 'function') { continue; } for (const connection of bucket.values()) { - const existingDeviceId = normalizeDeviceId(connection && connection.deviceId ? connection.deviceId : ''); - const existingClientId = normalizeDeviceId(connection && connection.clientId ? connection.clientId : ''); - if (existingDeviceId) { - liveDeviceIds.add(existingDeviceId); - } - if (existingClientId) { - liveClientIds.add(existingClientId); - } - const existingName = String(connection && connection.clientName ? connection.clientName : '').trim(); - if (!existingName) { - continue; - } - if (existingName.toLowerCase() !== lowerName) { - continue; - } - if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) { - continue; - } - return false; + allConnections.push({ + clientId: connection.clientId || null, + clientName: connection.clientName || null, + deviceId: connection.deviceId || null + }); } } + return allConnections; + } - const activePool = poolArg || pool; - if (!activePool || (!liveDeviceIds.size && !liveClientIds.size)) { - return true; - } - - try { - const [deviceRows] = await activePool.query( - `SELECT device_id - FROM player_onboarding_devices - WHERE client_name IS NOT NULL - AND TRIM(client_name) <> '' - AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))`, - [normalizedName] - ); - - for (let i = 0; i < deviceRows.length; i += 1) { - const deviceId = normalizeDeviceId(deviceRows[i] && deviceRows[i].device_id ? deviceRows[i].device_id : ''); - if (!deviceId) { - continue; - } - if (normalizedDeviceId && deviceId === normalizedDeviceId) { - continue; - } - if (liveDeviceIds.has(deviceId)) { - return false; - } - } - } catch (_error) { - return true; - } - - return true; + async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) { + return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections()); } function broadcastConnectionSnapshot(slug) { @@ -227,7 +177,7 @@ function createPlayerRuntime(options) { }); } - function sendCommandToConnection(slug, connectionId, commandOrPayload) { + async function sendCommandToConnection(slug, connectionId, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { return 0; @@ -249,7 +199,7 @@ function createPlayerRuntime(options) { return 1; } - function broadcastCommand(slug, commandOrPayload) { + async function broadcastCommand(slug, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { return 0; @@ -269,6 +219,7 @@ function createPlayerRuntime(options) { connection.socket.send(JSON.stringify(payload)); sent += 1; }); + return sent; } @@ -405,6 +356,7 @@ function createPlayerRuntime(options) { return { installWebsocket: installWebsocket, snapshotConnections: snapshotConnections, + snapshotAllConnections: snapshotAllConnections, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, sendCommandToConnection: sendCommandToConnection, broadcastCommand: broadcastCommand diff --git a/src/web.js b/src/web.js index 0885570..9c52826 100644 --- a/src/web.js +++ b/src/web.js @@ -16,6 +16,7 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-comm const registerAdminContentRoutes = require('./web/routes/admin-content'); const { createWebBootstrap } = require('./web/bootstrap'); const { createPlayerActionService } = require('./web/player-actions'); +const { isClientNameAvailable } = require('./client-name-check'); const { createSessionService } = require('./web/session'); const { formatDashboardDate, @@ -168,6 +169,7 @@ async function start() { notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage, + getScreenConnections: playerActionService.getScreenConnections, getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage, forwardPlayerCommand: playerActionService.forwardPlayerCommand, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL @@ -175,7 +177,9 @@ async function start() { registerAdminScreenCommandRoutes(app, { pool: pool, - forwardPlayerCommand: playerActionService.forwardPlayerCommand + forwardPlayerCommand: playerActionService.forwardPlayerCommand, + getScreenConnections: playerActionService.getScreenConnections, + isClientNameAvailable: isClientNameAvailable }); registerAdminContentRoutes(app, { diff --git a/src/web/dashboard-state.js b/src/web/dashboard-state.js index 1cc0df1..f4fcc7b 100644 --- a/src/web/dashboard-state.js +++ b/src/web/dashboard-state.js @@ -8,7 +8,7 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName return (screens || []).map(function (screen) { const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; return Object.assign({}, screen, { - client_name: onboardingNameBySlug[screen.slug] || screen.client_name || null, + client_name: onboardingNameBySlug[screen.slug] || null, player_connection_count: connectionState.count || 0, player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [] }); diff --git a/src/web/player-actions.js b/src/web/player-actions.js index e1bbc58..1938396 100644 --- a/src/web/player-actions.js +++ b/src/web/player-actions.js @@ -34,9 +34,45 @@ function createPlayerActionService(options) { }); } - async function getScreenDeleteBlockMessage(pool, screen) { + async function getScreenConnections(slug) { + const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { + method: 'GET', + headers: { + Accept: 'application/json' + } + }); + + if (!response.ok) { + const errorText = await response.text().catch(function () { return ''; }); + const error = new Error(errorText || `Unable to load screen connections for ${slug}.`); + error.statusCode = response.status; + throw error; + } + + return response.json().catch(function () { + return { connections: [] }; + }); + } + + async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) { const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]); - return Number(rows[0] && rows[0].ref_count) > 0 ? 'This screen is still linked to onboarding devices.' : ''; + if (Number(rows[0] && rows[0].ref_count) > 0) { + return 'This screen is still linked to onboarding devices.'; + } + + if (typeof getScreenConnections === 'function' && String(screen && screen.slug ? screen.slug : '').trim()) { + try { + const response = await getScreenConnections(screen.slug); + const liveConnections = Array.isArray(response && response.connections) ? response.connections : []; + if (liveConnections.length > 0) { + return 'This screen is still in use by connected players.'; + } + } catch (_error) { + // Keep the delete guard based on onboarding references if live connection lookup fails. + } + } + + return ''; } async function getSlideDeleteBlockMessage(pool, slide) { @@ -61,6 +97,7 @@ function createPlayerActionService(options) { return { forwardPlayerCommand: forwardPlayerCommand, + getScreenConnections: getScreenConnections, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index f180508..5028ae1 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -399,7 +399,14 @@ }).then(function (response) { if (!response.ok) { return response.text().then(function (text) { - throw new Error(text || 'Unable to rename client.'); + var message = text || 'Unable to rename client.'; + try { + var payload = JSON.parse(text); + message = payload && (payload.error || payload.message) ? String(payload.error || payload.message) : message; + } catch (_error) { + // fall back to the raw text body + } + throw new Error(message); }); } return response.json().catch(function () { diff --git a/src/web/public/js/toast.js b/src/web/public/js/toast.js index 6cc268a..f96d2b4 100644 --- a/src/web/public/js/toast.js +++ b/src/web/public/js/toast.js @@ -40,7 +40,7 @@ function getMessageVariant(message, fallbackVariant) { var text = String(message || '').trim(); - if (/^(unable to delete|cannot delete|can't delete)/i.test(text)) { + if (/^(unable to delete|cannot delete|can't delete)|\bstill (?:in use|linked|assigned|used)\b/i.test(text)) { return 'danger'; } return String(fallbackVariant || 'success').trim().toLowerCase() || 'success'; diff --git a/src/web/routes/admin-manage.js b/src/web/routes/admin-manage.js index 175e059..7b1591b 100644 --- a/src/web/routes/admin-manage.js +++ b/src/web/routes/admin-manage.js @@ -15,6 +15,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { 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; @@ -529,7 +530,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { if (!screen) { return res.status(404).send('Screen not found'); } - const blockMessage = await getScreenDeleteBlockMessage(pool, screen); + const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections); if (blockMessage) { return res.redirect('/admin/screens?message=' + encodeURIComponent(blockMessage)); } diff --git a/src/web/routes/admin-screen-commands.js b/src/web/routes/admin-screen-commands.js index 5459f2e..e277cc7 100644 --- a/src/web/routes/admin-screen-commands.js +++ b/src/web/routes/admin-screen-commands.js @@ -1,6 +1,8 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) { const pool = deps.pool; const forwardPlayerCommand = deps.forwardPlayerCommand; + const getScreenConnections = deps.getScreenConnections; + const isClientNameAvailable = deps.isClientNameAvailable; app.post('/admin/screens/:slug/commands', async function (req, res, next) { try { @@ -23,6 +25,106 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) { return res.status(404).json({ error: 'Screen not found' }); } + 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 player_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 + }); + } + let liveConnections = []; + try { + const [screenSlugs] = await pool.query('SELECT slug FROM 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 = typeof isClientNameAvailable === 'function' + ? await isClientNameAvailable(pool, clientName, deviceId, liveConnections) + : true; + 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); + + 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 player_onboarding_devices pod + JOIN 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 + }); + } + const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? Object.assign({}, req.body, { command: command }) : { command: command };