// Announcement route registration and status wiring. module.exports = function registerAnnouncementRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; const forwardAnnouncementRefresh = deps.forwardAnnouncementRefresh; const getAuditUserId = deps.getAuditUserId; const redirectAfterSave = deps.redirectAfterSave; const requirePermission = deps.requirePermission; const { buildPagination } = require('../../../lib/pagination'); const announcementData = require('#src/data/announcements'); const announcementIcons = require('#src/data/announcement-icons'); const { fetchAppSettings } = require('#src/data/app-settings'); const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('./duplicate'); const LIST_PAGE_SIZE = 25; const ANNOUNCEMENT_TYPES = [ { value: 'lower-third', label: 'Lower third', icon: 'arrow-bar-down' }, { value: 'fullscreen', label: 'Fullscreen', icon: 'arrows-fullscreen' }, { value: 'top-banner', label: 'Top banner', icon: 'arrow-bar-up' } ]; const ANNOUNCEMENT_COLORS = [ { value: 'primary', label: 'Primary' }, { value: 'secondary', label: 'Secondary' }, { value: 'success', label: 'Success' }, { value: 'info', label: 'Info' }, { value: 'warning', label: 'Warning' }, { value: 'danger', label: 'Danger' }, { value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' } ]; const ANNOUNCEMENT_ICONS = announcementIcons.ANNOUNCEMENT_ICON_OPTIONS || []; const ANNOUNCEMENT_ICON_CATALOG = announcementIcons.ANNOUNCEMENT_ICON_CATALOG || ANNOUNCEMENT_ICONS; async function fetchSuggestedAnnouncementIcons() { const settings = await fetchAppSettings(pool); const catalogByKey = new Map(ANNOUNCEMENT_ICON_CATALOG.map(function (option) { return [option.value, option]; })); const configuredKeys = Array.isArray(settings['announcements.suggested_icons']) ? settings['announcements.suggested_icons'] : []; const configuredOptions = configuredKeys.map(function (key) { return catalogByKey.get(String(key || '').trim().toLowerCase()) || null; }).filter(Boolean); return configuredOptions.length ? configuredOptions : ANNOUNCEMENT_ICONS; } function normalizeDurationUnit(value) { return String(value || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes'; } function computeDurationSeconds(value, unit) { const normalizedUnit = normalizeDurationUnit(unit); const amount = Math.max(1, Math.floor(Number(value) || 0)); return normalizedUnit === 'seconds' ? amount : amount * 60; } function formatDurationLabel(seconds) { const totalSeconds = Math.max(1, Math.round(Number(seconds) || 0)); if (totalSeconds % 60 === 0) { const minutes = totalSeconds / 60; return minutes === 1 ? '1 minute' : `${minutes} minutes`; } return totalSeconds === 1 ? '1 second' : `${totalSeconds} seconds`; } function isAnnouncementActive(announcement) { if (!announcement || !announcement.expires_at) { return true; } const expiresAt = new Date(announcement.expires_at).getTime(); if (Number.isNaN(expiresAt)) { return true; } return expiresAt > Date.now(); } function isAnnouncementDeletionBlocked(announcement) { return isAnnouncementActive(announcement); } async function fetchAllScreenSlugs() { const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC'); return (rows || []) .map(function (row) { return String(row && row.slug ? row.slug : '').trim(); }) .filter(Boolean); } async function fetchAnnouncementScreens() { const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, slug ASC'); return (rows || []).map(function (row) { return { id: Number(row && row.id) || null, name: String(row && row.name ? row.name : '').trim(), slug: String(row && row.slug ? row.slug : '').trim() }; }).filter(function (screen) { return Boolean(screen.id && screen.slug); }); } function parseSelectedScreenIds(value) { const values = Array.isArray(value) ? value : value === undefined || value === null || value === '' ? [] : [value]; return values.map(function (screenId) { return Number(screenId) || 0; }).filter(function (screenId) { return screenId > 0; }); } function buildAnnouncementScreenOptions(screens, selectedScreenIds) { const normalizedSelectedIds = (Array.isArray(selectedScreenIds) ? selectedScreenIds : []) .map(function (screenId) { return Number(screenId) || 0; }) .filter(function (screenId) { return screenId > 0; }); const selectedIds = new Set(normalizedSelectedIds); return (screens || []).map(function (screen) { return Object.assign({}, screen, { isSelected: selectedIds.has(Number(screen && screen.id) || 0) }); }); } async function updateAnnouncementScreenTargets(announcementId, selectedScreenIds, actorId) { const [screenRows] = await pool.query('SELECT id FROM d_screens ORDER BY id ASC'); const allScreenIds = (screenRows || []).map(function (row) { return Number(row && row.id) || 0; }).filter(function (screenId) { return screenId > 0; }); const selectedIds = parseSelectedScreenIds(selectedScreenIds).filter(function (screenId) { return allScreenIds.indexOf(screenId) !== -1; }); const [existingRows] = await pool.query('SELECT screen_id FROM d_announcement_screens WHERE announcement_id = ?', [announcementId]); const existingIds = new Set((existingRows || []).map(function (row) { return Number(row && row.screen_id) || 0; }).filter(function (screenId) { return screenId > 0; })); const selectedIdSet = new Set(selectedIds); const removedIds = (existingRows || []).map(function (row) { return Number(row && row.screen_id) || 0; }).filter(function (screenId) { return screenId > 0 && !selectedIdSet.has(screenId); }); if (removedIds.length) { await pool.query('DELETE FROM d_announcement_screens WHERE announcement_id = ? AND screen_id IN (?)', [announcementId, removedIds]); } if (selectedIds.length) { for (const screenId of selectedIds) { await pool.query( `UPDATE d_announcement_screens SET modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE announcement_id = ? AND screen_id = ?`, [actorId || null, announcementId, screenId] ); await pool.query( `INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by) SELECT ?, ?, ?, ? WHERE NOT EXISTS ( SELECT 1 FROM d_announcement_screens WHERE announcement_id = ? AND screen_id = ? )`, [announcementId, screenId, actorId || null, actorId || null, announcementId, screenId] ); } } } async function refreshAnnouncementPlayers() { if (typeof forwardAnnouncementRefresh !== 'function') { return; } try { const slugs = await fetchAllScreenSlugs(); if (!slugs.length) { return; } await Promise.allSettled(slugs.map(function (slug) { return forwardAnnouncementRefresh(slug); })); } catch (_error) { // Keep announcement persistence independent from player refresh delivery. } } async function deactivateConflictingAnnouncements(announcementId, screenIds, actorId) { const normalizedScreenIds = (Array.isArray(screenIds) ? screenIds : []) .map(function (screenId) { return Number(screenId) || 0; }) .filter(function (screenId) { return screenId > 0; }); if (!normalizedScreenIds.length) { return; } const conflictingIds = new Set(); const [targetedRows] = await pool.query( `SELECT DISTINCT a.id FROM d_announcements a JOIN d_announcement_screens restriction ON restriction.announcement_id = a.id WHERE a.id <> ? AND (a.expires_at IS NULL OR a.expires_at > CURRENT_TIMESTAMP) AND restriction.screen_id IN (?)`, [announcementId, normalizedScreenIds] ); (targetedRows || []).forEach(function (row) { const id = Number(row && row.id) || 0; if (id > 0) { conflictingIds.add(id); } }); if (!conflictingIds.size) { return; } await pool.query( 'UPDATE d_announcements SET expires_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id IN (?)', [actorId || null, Array.from(conflictingIds)] ); } function buildAnnouncementFormData(announcement) { const normalized = announcement || {}; const durationSeconds = Number(normalized.duration_seconds || 0) || 0; const durationValue = durationSeconds > 0 && durationSeconds % 60 === 0 ? durationSeconds / 60 : durationSeconds; const durationUnit = durationSeconds > 0 && durationSeconds % 60 === 0 ? 'minutes' : 'seconds'; const durationMode = String(normalized.durationMode || '').trim() || (durationSeconds > 0 ? 'duration' : 'until_disabled'); const isActive = isAnnouncementActive(normalized); const canDelete = Boolean(normalized.id) && !isAnnouncementDeletionBlocked(normalized); const hasTargets = Array.isArray(normalized.screen_ids) && normalized.screen_ids.length > 0; const deleteBlockedMessage = canDelete ? '' : 'This announcement is currently active and cannot be deleted.'; return Object.assign({}, normalized, { durationMode: durationMode, durationValue: durationSeconds > 0 ? durationValue : Number(normalized.durationValue || 5) || 5, durationUnit: durationSeconds > 0 ? durationUnit : String(normalized.durationUnit || 'minutes'), durationLabel: durationSeconds > 0 ? formatDurationLabel(durationSeconds) : 'Until disabled', isActive: isActive, actionLabel: isActive ? 'Stop' : 'Play', actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill', actionDisabled: !isActive && !hasTargets, actionClassName: !isActive && !hasTargets ? 'btn-outline-info' : (isActive ? 'btn-outline-warning' : 'btn-info'), actionDisabledTitle: !isActive && !hasTargets ? 'Select at least one screen group to play this announcement.' : '', actionConfirmMessage: isActive ? 'Stop this announcement on the selected screens now?' : 'Send this announcement to the selected screens now?', actionPath: isActive ? `/announcements/${normalized.id}/stop` : `/announcements/${normalized.id}/play`, canDelete: canDelete, deleteBlockedMessage: deleteBlockedMessage }); } app.get('/announcements', requirePermission('announcements.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req) || 'description'; const direction = common.getSortDirectionQuery(req); const data = await common.fetchAnnouncementsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderAnnouncementsPage({ announcements: data.announcements || [], pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'announcements', 'Announcement pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) { try { const screens = await fetchAnnouncementScreens(); const suggestedAnnouncementIcons = await fetchSuggestedAnnouncementIcons(); const settings = await fetchAppSettings(pool); const configuredIcon = String(settings['announcements.default_icon'] || '').trim().toLowerCase(); const defaultIcon = ANNOUNCEMENT_ICON_CATALOG.some(function (option) { return option.value === configuredIcon; }) ? configuredIcon : (suggestedAnnouncementIcons[0] && suggestedAnnouncementIcons[0].value ? suggestedAnnouncementIcons[0].value : announcementIcons.DEFAULT_ANNOUNCEMENT_ICON); res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData({ message: '', short_label: '', announcement_type: 'lower-third', color_key: 'primary', icon_key: defaultIcon, durationMode: 'duration', duration_seconds: null, durationValue: Number(settings['announcements.default_duration_value']) || 10, durationUnit: String(settings['announcements.default_duration_unit'] || 'seconds'), screen_ids: [] }), req.query.message ? String(req.query.message) : '', req.currentUser, { announcementTypes: ANNOUNCEMENT_TYPES, announcementColors: ANNOUNCEMENT_COLORS, announcementIcons: suggestedAnnouncementIcons, announcementIconCatalog: ANNOUNCEMENT_ICON_CATALOG, announcementScreens: buildAnnouncementScreenOptions(screens, []) })); } catch (error) { next(error); } }); app.get('/announcements/:id/edit', requirePermission('announcements.update'), async function (req, res, next) { try { const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id)); if (!announcement) { return res.status(404).send('Announcement not found'); } const screens = await fetchAnnouncementScreens(); res.send(pages.renderAnnouncementEditPage(buildAnnouncementFormData(announcement), null, req.query.message ? String(req.query.message) : '', req.currentUser, { announcementTypes: ANNOUNCEMENT_TYPES, announcementColors: ANNOUNCEMENT_COLORS, announcementIcons: await fetchSuggestedAnnouncementIcons(), announcementIconCatalog: ANNOUNCEMENT_ICON_CATALOG, announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || []) })); } catch (error) { next(error); } }); app.get('/announcements/:id/duplicate', requirePermission('announcements.read'), requirePermission('announcements.create'), async function (req, res, next) { try { const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id)); if (!announcement) { return res.status(404).send('Announcement not found'); } const screens = await fetchAnnouncementScreens(); let duplicateName = buildDuplicateAnnouncementName(announcement.short_label || announcement.message || 'Announcement'); let duplicateIndex = 2; while (await common.fetchDuplicateName(pool, 'd_announcements', duplicateName, null, 'short_label')) { duplicateName = buildDuplicateAnnouncementName(announcement.short_label || announcement.message || 'Announcement') + ' (' + duplicateIndex + ')'; duplicateIndex += 1; } res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData(buildDuplicateAnnouncement(announcement, duplicateName)), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, { announcementTypes: ANNOUNCEMENT_TYPES, announcementColors: ANNOUNCEMENT_COLORS, announcementIcons: await fetchSuggestedAnnouncementIcons(), announcementIconCatalog: ANNOUNCEMENT_ICON_CATALOG, announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || []), messageVariant: 'info' })); } catch (error) { next(error); } }); app.post('/announcements', requirePermission('announcements.create'), async function (req, res, next) { try { const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim(); const shortLabel = String(req.body.short_label || '').replace(/\r\n/g, ' ').trim(); const announcementType = common.normalizeAnnouncementType(req.body.announcement_type); const colorKey = common.normalizeAnnouncementColor(req.body.color_key); const iconKey = common.normalizeAnnouncementIcon(req.body.icon_key); const durationMode = String(req.body.duration_mode || 'until_disabled').trim(); const durationSeconds = durationMode === 'duration' ? computeDurationSeconds(req.body.duration_value, req.body.duration_unit) : null; const selectedScreenIds = parseSelectedScreenIds(req.body.screen_ids); if (!message) { return res.status(400).send('Announcement text is required.'); } const actorId = getAuditUserId(req); const [result] = await pool.query( 'INSERT INTO d_announcements (message, short_label, announcement_type, color_key, icon_key, duration_seconds, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)', [message, shortLabel, announcementType, colorKey, iconKey, durationSeconds, actorId, actorId] ); await updateAnnouncementScreenTargets(result.insertId, selectedScreenIds, actorId); redirectAfterSave(req, res, '/announcements?edit=' + result.insertId, { closeUrl: '/announcements', newUrl: '/announcements/new', message: 'Announcement created.' }); } catch (error) { next(error); } }); app.post('/announcements/:id', requirePermission('announcements.update'), async function (req, res, next) { try { const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id)); if (!announcement) { return res.status(404).send('Announcement not found'); } const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim(); const shortLabel = String(req.body.short_label || '').replace(/\r\n/g, ' ').trim(); const announcementType = common.normalizeAnnouncementType(req.body.announcement_type); const colorKey = common.normalizeAnnouncementColor(req.body.color_key); const iconKey = common.normalizeAnnouncementIcon(req.body.icon_key); const durationMode = String(req.body.duration_mode || 'until_disabled').trim(); const durationSeconds = durationMode === 'duration' ? computeDurationSeconds(req.body.duration_value, req.body.duration_unit) : null; const selectedScreenIds = parseSelectedScreenIds(req.body.screen_ids); if (!message) { return res.status(400).send('Announcement text is required.'); } const actorId = getAuditUserId(req); await pool.query( 'UPDATE d_announcements SET message = ?, short_label = ?, announcement_type = ?, color_key = ?, icon_key = ?, duration_seconds = ?, modified_by = ? WHERE id = ?', [message, shortLabel, announcementType, colorKey, iconKey, durationSeconds, actorId, announcement.id] ); await updateAnnouncementScreenTargets(announcement.id, selectedScreenIds, actorId); redirectAfterSave(req, res, '/announcements?edit=' + announcement.id, { closeUrl: '/announcements', newUrl: '/announcements/new', message: 'Announcement updated.' }); } catch (error) { next(error); } }); app.post('/announcements/:id/play', requirePermission('announcements.update'), async function (req, res, next) { try { const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id)); if (!announcement) { return res.status(404).send('Announcement not found'); } await deactivateConflictingAnnouncements(announcement.id, announcement.screen_ids || [], getAuditUserId(req)); const durationSeconds = announcement.duration_seconds ? Number(announcement.duration_seconds) : null; const expiresAt = durationSeconds ? new Date(Date.now() + (durationSeconds * 1000)) : null; await pool.query('UPDATE d_announcements SET expires_at = ?, modified_by = ? WHERE id = ?', [expiresAt, getAuditUserId(req), announcement.id]); await refreshAnnouncementPlayers(); if (req.xhr || String(req.headers.accept || '').includes('application/json')) { return res.json({ ok: true, message: 'Announcement sent to screens.', announcementId: announcement.id }); } res.redirect('/announcements?edit=' + announcement.id + '&message=' + encodeURIComponent('Announcement sent to screens.')); } catch (error) { next(error); } }); app.post('/announcements/:id/stop', requirePermission('announcements.update'), async function (req, res, next) { try { const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id)); if (!announcement) { return res.status(404).send('Announcement not found'); } await pool.query('UPDATE d_announcements SET expires_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ?', [getAuditUserId(req), announcement.id]); await refreshAnnouncementPlayers(); if (req.xhr || String(req.headers.accept || '').includes('application/json')) { return res.json({ ok: true, message: 'Announcement stopped.', announcementId: announcement.id }); } res.redirect('/announcements?edit=' + announcement.id + '&message=' + encodeURIComponent('Announcement stopped.')); } catch (error) { next(error); } }); app.post('/announcements/:id/delete', requirePermission('announcements.delete'), async function (req, res, next) { try { const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id)); if (!announcement) { return res.status(404).send('Announcement not found'); } if (isAnnouncementDeletionBlocked(announcement)) { return res.redirect('/announcements/' + announcement.id + '/edit?message=' + encodeURIComponent('This announcement is currently active and cannot be deleted.')); } await pool.query('DELETE FROM d_announcements WHERE id = ?', [announcement.id]); await refreshAnnouncementPlayers(); res.redirect('/announcements?message=' + encodeURIComponent('Announcement deleted.')); } catch (error) { next(error); } }); };