Files
pulse-signage/src/web/routes/admin-content.js
T
lzstealth 2ea8d389fa This PR breaks the large web and player bootstrap files into smaller modules with clearer ownership.
Web changes:

Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of web.js.
Kept web.js focused on wiring and server startup.
Fixed screen playlist reassignment so changing a screen’s playlist now triggers a refresh.
Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
Player changes:

Split websocket/runtime handling into runtime.js.
Split playlist assembly and revision hashing into playlist.js.
Split onboarding and player HTTP routes into dedicated modules.
Split render utilities and template loading into render-helpers.js.
Kept player.js mostly as startup/orchestration.
Validation:

Rebuilt both services with Docker Compose.
Smoke-checked web and player routes after the refactor.
Verified get_errors was clean on the touched modules.
2026-07-20 23:58:27 +01:00

392 lines
17 KiB
JavaScript

module.exports = function registerAdminContentRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const upload = deps.upload;
const fetchScreensBySlideId = deps.fetchScreensBySlideId;
const fetchScreensByTemplateId = deps.fetchScreensByTemplateId;
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage;
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== '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') {
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
}
async function canvasSizeExists(width, height, ignoreId) {
const params = [width, height];
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
if (ignoreId) {
query += ' AND id <> ?';
params.push(ignoreId);
}
const [rows] = await pool.query(query, params);
return Number(rows[0] && rows[0].count) > 0;
}
async function fetchScreenSlideCountsBySlug(screenSlugs) {
const uniqueSlugs = Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).map(function (value) {
return String(value || '').trim();
}).filter(Boolean);
if (!uniqueSlugs.length) {
return {};
}
const [rows] = await pool.query(
`SELECT s.slug, COUNT(ps.id) AS slide_count
FROM screens s
LEFT JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE s.slug IN (?)
GROUP BY s.id, s.slug`,
[uniqueSlugs]
);
return rows.reduce(function (counts, row) {
counts[String(row.slug || '').trim()] = Number(row.slide_count) || 0;
return counts;
}, {});
}
app.get('/admin/slides', async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/slides/new', async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/slides/:id/edit', async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/slides', upload.any(), async function (req, res, next) {
try {
const payload = await common.buildSlidePayload(pool, req, null);
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId]
);
await syncPlaylistUploadsOnChange({
key: 'slide:create:' + result.insertId,
pool: pool,
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
redirectAfterSave(req, res, '/admin/slides/' + result.insertId + '/edit', {
closeUrl: '/admin/slides',
newUrl: '/admin/slides/new',
message: 'Slide created.'
});
} catch (error) {
next(error);
}
});
app.post('/admin/slides/:id', upload.any(), async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
const payload = await common.buildSlidePayload(pool, req, slide);
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id]
);
await syncPlaylistUploadsOnChange({
key: 'slide:update:' + slide.id,
pool: pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs,
blockedSlideIds: [slide.id],
refreshScreenSlugs: affectedScreens,
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
redirectAfterSave(req, res, '/admin/slides/' + slide.id + '/edit', {
closeUrl: '/admin/slides',
newUrl: '/admin/slides/new',
message: 'Slide updated.'
});
} catch (error) {
next(error);
}
});
app.post('/admin/slides/:id/delete', async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const blockMessage = await getSlideDeleteBlockMessage(pool, slide);
if (blockMessage) {
return res.redirect('/admin/slides?message=' + encodeURIComponent(blockMessage));
}
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const uploadRefs = collectUploadReferencesFromSlide(slide);
await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]);
await syncPlaylistUploadsOnChange({
key: 'slide:delete:' + slide.id,
pool: pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: uploadRefs,
blockedSlideIds: [slide.id],
refreshScreenSlugs: affectedScreens,
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/templates', async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/templates/new', 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));
} catch (error) {
next(error);
}
});
app.post('/admin/templates', upload.any(), async function (req, res, next) {
try {
const payload = await common.buildTemplatePayload(pool, req, null);
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slide_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]
);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
key: 'template:create:' + result.insertId,
pool: pool,
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
redirectAfterSave(req, res, '/admin/templates/' + result.insertId + '/edit', {
closeUrl: '/admin/templates',
newUrl: '/admin/templates/new',
message: 'Template created.'
});
} catch (error) {
next(error);
}
});
app.get('/admin/templates/:id/edit', async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
return res.status(404).send('Template not found');
}
const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/templates/:id', upload.any(), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
return res.status(404).send('Template not found');
}
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
const payload = await common.buildTemplatePayload(pool, req, template);
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE slide_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]
);
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
key: 'template:update:' + template.id,
pool: pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
redirectAfterSave(req, res, '/admin/templates/' + template.id + '/edit', {
closeUrl: '/admin/templates',
newUrl: '/admin/templates/new',
message: 'Template updated.'
});
} catch (error) {
next(error);
}
});
app.post('/admin/templates/:id/delete', async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
return res.status(404).send('Template not found');
}
const blockMessage = await getTemplateDeleteBlockMessage(pool, template);
if (blockMessage) {
return res.redirect('/admin/templates?message=' + encodeURIComponent(blockMessage));
}
const uploadRefs = collectUploadReferencesFromTemplate(template);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]);
await syncPlaylistUploadsOnChange({
key: 'template:delete:' + template.id,
pool: pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: uploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/canvas-sizes', async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/canvas-sizes/new', function (req, res) {
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/admin/canvas-sizes', async function (req, res, next) {
try {
const payload = common.buildCanvasSizePayload(req, null);
if (await canvasSizeExists(payload.width, payload.height)) {
return res.redirect('/admin/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
}
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
redirectAfterSave(req, res, '/admin/canvas-sizes', {
closeUrl: '/admin/canvas-sizes',
newUrl: '/admin/canvas-sizes/new',
message: 'Canvas size created.'
});
} catch (error) {
next(error);
}
});
app.get('/admin/canvas-sizes/:id/edit', async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/canvas-sizes/:id', async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
const payload = common.buildCanvasSizePayload(req, canvasSize);
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
return res.redirect('/admin/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
}
await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
redirectAfterSave(req, res, '/admin/canvas-sizes', {
closeUrl: '/admin/canvas-sizes',
newUrl: '/admin/canvas-sizes/new',
message: 'Canvas size updated.'
});
} catch (error) {
next(error);
}
});
app.post('/admin/canvas-sizes/:id/delete', async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
const blockMessage = await getCanvasSizeDeleteBlockMessage(pool, canvasSize);
if (blockMessage) {
return res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent(blockMessage));
}
await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]);
res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
} catch (error) {
next(error);
}
});
};