Save worktree changes
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
|
||||
app.get('/account', function (req, res) {
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
|
||||
});
|
||||
|
||||
app.post('/account/name', async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).send('Name is required.');
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, req.currentUser.id)) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/account/password', async function (req, res, next) {
|
||||
try {
|
||||
const currentPassword = String(req.body.current_password || '');
|
||||
const newPassword = String(req.body.new_password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
return res.status(400).send('Current password is incorrect.');
|
||||
}
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return res.status(400).send('New password must be at least 8 characters.');
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
return res.status(400).send('New passwords do not match.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(newPassword);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [user.id]);
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||
const withClientNameReservation = deps.withClientNameReservation;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
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 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' });
|
||||
}
|
||||
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
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
|
||||
});
|
||||
}
|
||||
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 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);
|
||||
|
||||
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 };
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const result = connectionId
|
||||
? await forwardPlayerCommand(slug, commandPayload, connectionId)
|
||||
: await forwardPlayerCommand(slug, commandPayload);
|
||||
|
||||
return res.json(Object.assign({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null
|
||||
}, result && typeof result === 'object' ? result : {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,443 @@
|
||||
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;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
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('/slides', requirePermission('slides.read'), 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('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/:id/edit', requirePermission('slides.update'), 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);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildSlidePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
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, '/slides/' + result.insertId + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
message: 'Slide created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/:id', requirePermission('slides.update'), 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);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, slide.id, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
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, '/slides/' + slide.id + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
message: 'Slide updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/:id/delete', requirePermission('slides.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('/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('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/templates', requirePermission('templates.read'), 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('/templates/new', requirePermission('templates.create'), 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('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildTemplatePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name)) {
|
||||
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
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, lock_ratio, 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.lock_ratio, 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, '/templates/' + result.insertId + '/edit', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
message: 'Template created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/templates/:id/edit', requirePermission('templates.update'), 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('/templates/:id', requirePermission('templates.update'), 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);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
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, lock_ratio, 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.lock_ratio, 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, '/templates/' + template.id + '/edit', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
message: 'Template updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/templates/:id/delete', requirePermission('templates.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('/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('/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), 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('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
||||
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const payload = common.buildCanvasSizePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = 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, '/canvas-sizes/' + result.insertId + '/edit', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
message: 'Canvas size created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), 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('/canvas-sizes/:id', requirePermission('canvas-sizes.update'), 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 common.fetchDuplicateName(pool, 'canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
return res.redirect('/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, '/canvas-sizes', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
message: 'Canvas size updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.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('/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('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
|
||||
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
|
||||
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, apiUrl, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await loadApiSourceResponse(apiUrl);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
updatedItems = await loadRssFeedItems(feedUrl, itemLimit);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET modified_by = ? WHERE id = ?',
|
||||
[actorId, rssFeedId]
|
||||
);
|
||||
if (replaceRssFeedItems) {
|
||||
await replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.commit();
|
||||
|
||||
if (pullError) {
|
||||
console.error('[admin-data-sources] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
|
||||
throw new Error('registerAdminDataSourceRoutes requires the data source route dependencies.');
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: require('../../lib/background-task-queue').normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRssFeedItems(feedUrl, itemLimit) {
|
||||
if (typeof fetchRssFeedItems === 'function') {
|
||||
return fetchRssFeedItems(feedUrl, itemLimit);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadApiSourceResponse(apiUrl) {
|
||||
if (typeof fetchApiSourceResponse === 'function') {
|
||||
return fetchApiSourceResponse(apiUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
responseJson: null,
|
||||
responseStatus: null,
|
||||
responseContentType: null
|
||||
};
|
||||
}
|
||||
|
||||
function sendRefreshTaskState(req, res, sourceType, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = getTaskStatusById(taskId);
|
||||
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
app.get('/data-sources', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
|
||||
return res.redirect('/data-sources/rss-feeds');
|
||||
}
|
||||
return res.redirect('/data-sources/api-sources');
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchApiSourcesData(pool);
|
||||
res.send(pages.renderApiSourcesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
|
||||
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO api_sources (name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(result.insertId, payload.apiUrl, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'API source created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || ''
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET name = ?, api_url = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(apiSource.id, payload.apiUrl, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + apiSource.id,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchRssFeedsData(pool);
|
||||
res.send(pages.renderRssFeedsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
|
||||
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + result.insertId,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'RSS feed created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
|
||||
feedUrl: rssFeed.feed_url,
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit
|
||||
}), {
|
||||
pulledItems: pulledItems,
|
||||
pullError: ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + rssFeed.id,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,607 @@
|
||||
module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
|
||||
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
|
||||
const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature;
|
||||
const getCanvasSignature = deps.getCanvasSignature;
|
||||
const normalizeScheduleMode = deps.normalizeScheduleMode;
|
||||
const parseDateTimeLocal = deps.parseDateTimeLocal;
|
||||
const parseTimeLocal = deps.parseTimeLocal;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
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;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
const [rows] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
|
||||
return (rows || [])
|
||||
.map(function (row) {
|
||||
return String(row && row.slug ? row.slug : '').trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
|
||||
if (command !== 'reload' && command !== 'blackout') {
|
||||
return res.status(400).json({ error: 'Unsupported command' });
|
||||
}
|
||||
|
||||
const slugs = await fetchAllScreenSlugs();
|
||||
if (!slugs.length) {
|
||||
await broadcastDashboardState();
|
||||
return res.json({ ok: true, command: command, sent: 0 });
|
||||
}
|
||||
|
||||
const payload = command === 'blackout'
|
||||
? {
|
||||
command: 'blackout',
|
||||
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
|
||||
}
|
||||
: 'reload';
|
||||
|
||||
const sentCount = await notifyPlayerScreens(slugs, payload);
|
||||
await broadcastDashboardState();
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
command: command,
|
||||
sent: sentCount,
|
||||
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name, playlist.id)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
|
||||
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
|
||||
|
||||
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
||||
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
|
||||
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
|
||||
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
||||
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
||||
const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']);
|
||||
const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']);
|
||||
const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']);
|
||||
|
||||
if (durations.length && durations.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist slide data is invalid.');
|
||||
}
|
||||
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist schedule data is invalid.');
|
||||
}
|
||||
|
||||
const normalizedSlides = [];
|
||||
const seenSlideIds = new Set();
|
||||
for (let i = 0; i < slideIds.length; i += 1) {
|
||||
const slideId = Number(slideIds[i]);
|
||||
if (!Number.isInteger(slideId) || slideId <= 0) {
|
||||
return res.status(400).send('Invalid slide selection.');
|
||||
}
|
||||
if (seenSlideIds.has(slideId)) {
|
||||
return res.status(400).send('A slide can only be added to a playlist once.');
|
||||
}
|
||||
seenSlideIds.add(slideId);
|
||||
|
||||
const durationRaw = Number(durations[i]);
|
||||
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10;
|
||||
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
|
||||
|
||||
let scheduleStartDatetime = null;
|
||||
let scheduleEndDatetime = null;
|
||||
let scheduleStartTime = null;
|
||||
let scheduleEndTime = null;
|
||||
let scheduleDaysJson = null;
|
||||
|
||||
if (scheduleMode === 'dates') {
|
||||
scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]);
|
||||
scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]);
|
||||
if (!scheduleStartDatetime || !scheduleEndDatetime) {
|
||||
return res.status(400).send('Start and end datetimes are required for date scheduling.');
|
||||
}
|
||||
if (scheduleEndDatetime < scheduleStartDatetime) {
|
||||
return res.status(400).send('End datetime must be after start datetime.');
|
||||
}
|
||||
} else if (scheduleMode === 'times') {
|
||||
scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]);
|
||||
scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]);
|
||||
if (!scheduleStartTime || !scheduleEndTime) {
|
||||
return res.status(400).send('Start and end times are required for time scheduling.');
|
||||
}
|
||||
|
||||
let scheduleDays = [];
|
||||
try {
|
||||
const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]'));
|
||||
scheduleDays = Array.isArray(parsedDays) ? parsedDays : [];
|
||||
} catch (_error) {
|
||||
scheduleDays = [];
|
||||
}
|
||||
scheduleDays = scheduleDays
|
||||
.map(function (value) { return Number(value); })
|
||||
.filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; });
|
||||
if (!scheduleDays.length) {
|
||||
return res.status(400).send('Select at least one day for time scheduling.');
|
||||
}
|
||||
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
|
||||
}
|
||||
|
||||
normalizedSlides.push({
|
||||
slideId,
|
||||
position: i,
|
||||
durationSeconds,
|
||||
scheduleMode,
|
||||
scheduleStartDatetime,
|
||||
scheduleEndDatetime,
|
||||
scheduleStartTime,
|
||||
scheduleEndTime,
|
||||
scheduleDaysJson
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedSlides.length) {
|
||||
const [slides] = await connection.query(
|
||||
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides sl
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE sl.id IN (?)`,
|
||||
[normalizedSlides.map(function (item) { return item.slideId; })]
|
||||
);
|
||||
if (slides.length !== normalizedSlides.length) {
|
||||
return res.status(400).send('One or more selected slides no longer exist.');
|
||||
}
|
||||
const signatures = Array.from(new Set(
|
||||
slides
|
||||
.map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); })
|
||||
.filter(Boolean)
|
||||
));
|
||||
if (signatures.length > 1) {
|
||||
return res.status(400).send('All playlist slides must share the same canvas size.');
|
||||
}
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, actorId, playlist.id]);
|
||||
await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
await connection.query(
|
||||
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
playlist.id,
|
||||
item.slideId,
|
||||
item.position,
|
||||
item.durationSeconds,
|
||||
item.scheduleMode,
|
||||
item.scheduleStartDatetime,
|
||||
item.scheduleEndDatetime,
|
||||
item.scheduleStartTime,
|
||||
item.scheduleEndTime,
|
||||
item.scheduleDaysJson,
|
||||
actorId,
|
||||
actorId
|
||||
]
|
||||
);
|
||||
}
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]);
|
||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const slideId = Number(req.body.slide_id);
|
||||
if (!slideId) {
|
||||
return res.status(400).send('Slide is required.');
|
||||
}
|
||||
const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id);
|
||||
if (playlistCanvasSignature === 'mismatch') {
|
||||
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
|
||||
}
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height);
|
||||
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
||||
}
|
||||
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
|
||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[durationSeconds, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const direction = String(req.body.direction || '').toLowerCase();
|
||||
if (direction !== 'up' && direction !== 'down') {
|
||||
return res.status(400).send('Invalid move direction.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
|
||||
const currentIndex = orderedSlides.findIndex(function (item) {
|
||||
return Number(item.id) === playlistSlideId;
|
||||
});
|
||||
if (currentIndex === -1) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
}
|
||||
|
||||
const currentSlide = orderedSlides[currentIndex];
|
||||
const swapSlide = orderedSlides[swapIndex];
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
|
||||
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
let playlistSlide = (data.playlistSlides || []).find(function (item) {
|
||||
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
|
||||
});
|
||||
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always');
|
||||
const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null;
|
||||
const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null;
|
||||
const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null;
|
||||
const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null;
|
||||
const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]';
|
||||
const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || [];
|
||||
|
||||
playlistSlide = Object.assign({}, playlistSlide || {}, {
|
||||
id: playlistSlide ? playlistSlide.id : 0,
|
||||
schedule_mode: scheduleMode,
|
||||
schedule_days_json: scheduleDaysJson,
|
||||
schedule_days: scheduleDays,
|
||||
schedule_start_datetime: scheduleStartDatetime,
|
||||
schedule_end_datetime: scheduleEndDatetime,
|
||||
schedule_start_time: scheduleStartTime,
|
||||
schedule_end_time: scheduleEndTime
|
||||
});
|
||||
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const rowKey = String(req.body.row_key || '').trim();
|
||||
if (rowKey) {
|
||||
return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
let scheduleMode = normalizeScheduleMode(req.body.schedule_mode);
|
||||
let scheduleStartDatetime = null;
|
||||
let scheduleEndDatetime = null;
|
||||
let scheduleStartTime = null;
|
||||
let scheduleEndTime = null;
|
||||
let scheduleDaysJson = null;
|
||||
|
||||
const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime);
|
||||
const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time);
|
||||
const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length);
|
||||
|
||||
if (scheduleMode === 'dates' && !hasDateRange) {
|
||||
scheduleMode = 'always';
|
||||
} else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) {
|
||||
scheduleMode = 'always';
|
||||
}
|
||||
|
||||
if (scheduleMode === 'dates') {
|
||||
scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime);
|
||||
scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime);
|
||||
if (!scheduleStartDatetime || !scheduleEndDatetime) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartDatetime = null;
|
||||
scheduleEndDatetime = null;
|
||||
} else if (scheduleEndDatetime < scheduleStartDatetime) {
|
||||
return res.status(400).send('End datetime must be after start datetime.');
|
||||
}
|
||||
} else if (scheduleMode === 'times') {
|
||||
scheduleStartTime = parseTimeLocal(req.body.schedule_start_time);
|
||||
scheduleEndTime = parseTimeLocal(req.body.schedule_end_time);
|
||||
const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 6;
|
||||
});
|
||||
if (!scheduleStartTime || !scheduleEndTime) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartTime = null;
|
||||
scheduleEndTime = null;
|
||||
scheduleDaysJson = null;
|
||||
} else if (!scheduleDays.length) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartTime = null;
|
||||
scheduleEndTime = null;
|
||||
scheduleDaysJson = null;
|
||||
} else {
|
||||
if (scheduleEndTime < scheduleStartTime) {
|
||||
return res.status(400).send('End time must be after start time.');
|
||||
}
|
||||
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
|
||||
}
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchScreenEditData(pool);
|
||||
res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
message: 'Screen created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
const previousSlug = String(screen.slug || '').trim();
|
||||
await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||
}
|
||||
if (previousSlug && previousSlug !== slug) {
|
||||
await forwardPlayerCommand(previousSlug, {
|
||||
command: 'redirect',
|
||||
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
message: 'Screen updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]);
|
||||
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = function registerAdminPagesRoutes(app, deps) {
|
||||
require('../signage/dashboard/routes')(app, deps);
|
||||
require('../signage/clients/routes')(app, deps);
|
||||
require('../signage/screens/routes')(app, deps);
|
||||
require('../signage/playlists/routes')(app, deps);
|
||||
require('../signage/slides/routes')(app, deps);
|
||||
require('../signage/canvas-sizes/routes')(app, deps);
|
||||
require('../signage/templates/routes')(app, deps);
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const rbacData = deps.rbacData;
|
||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||
const readArrayField = deps.readArrayField;
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
}
|
||||
|
||||
function getActionLabel(actionKey) {
|
||||
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
|
||||
if (normalizedActionKey === 'edit') {
|
||||
return 'Update';
|
||||
}
|
||||
if (normalizedActionKey === 'allow') {
|
||||
return 'Allow';
|
||||
}
|
||||
if (!normalizedActionKey) {
|
||||
return 'Read';
|
||||
}
|
||||
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
|
||||
}
|
||||
|
||||
async function createUniqueRoleKey(baseName) {
|
||||
const baseKey = slugifyRoleKey(baseName);
|
||||
let candidate = baseKey;
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${baseKey}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
|
||||
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
|
||||
const permissionDefinitions = new Map(permissions.map(function (permission) {
|
||||
return [String(permission.key || '').trim(), permission];
|
||||
}));
|
||||
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
|
||||
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
|
||||
return Object.assign({}, permission, {
|
||||
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.name : permission.section_name,
|
||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||
actionKey: definition ? definition.actionKey : 'read',
|
||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapUsersForView(userRows, selectedUserIds) {
|
||||
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
|
||||
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isSelected: selectedIds.has(Number(user.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildPermissionGroups(permissionRows) {
|
||||
const groups = [];
|
||||
const groupIndex = new Map();
|
||||
|
||||
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
|
||||
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
if (!groupIndex.has(sectionKey)) {
|
||||
const group = {
|
||||
id: sectionKey || 'permissions',
|
||||
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
|
||||
categoryName: String(permission.categoryName || '').trim(),
|
||||
sectionOrder: Number(permission.sectionOrder) || 999,
|
||||
permissions: []
|
||||
};
|
||||
groupIndex.set(sectionKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
groupIndex.get(sectionKey).permissions.push(permission);
|
||||
});
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.permissions.sort(function (left, right) {
|
||||
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
|
||||
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
|
||||
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.name || '').localeCompare(String(right.name || ''));
|
||||
});
|
||||
});
|
||||
|
||||
groups.sort(function (left, right) {
|
||||
const leftOrder = Number(left.sectionOrder) || 999;
|
||||
const rightOrder = Number(right.sectionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoleViewModel(roleId) {
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
|
||||
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
return {
|
||||
role: Object.assign({}, role, {
|
||||
permissionKeys: selectedPermissionKeys,
|
||||
permissionCount: Number(role.permission_count) || 0,
|
||||
userCount: Number(role.user_count) || 0
|
||||
}),
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(users, selectedUserIds)
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await rbacData.fetchRoles(pool);
|
||||
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
|
||||
buildRoleCreateViewModel({
|
||||
name: String(req.query.name || '').trim(),
|
||||
description: String(req.query.description || '').trim()
|
||||
}, []).then(function (viewModel) {
|
||||
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
|
||||
}).catch(function (error) {
|
||||
next(error);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: []);
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name)) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
if (selectedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
const roleKey = await createUniqueRoleKey(name);
|
||||
const actorId = getAuditUserId(req);
|
||||
const connection = await pool.getConnection();
|
||||
let insertedRoleId = null;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
);
|
||||
insertedRoleId = Number(result.insertId);
|
||||
if (selectedPermissionKeys.length) {
|
||||
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const viewModel = await loadRoleViewModel(roleId);
|
||||
if (!viewModel) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const users = Array.isArray(viewModel.users)
|
||||
? viewModel.users.filter(function (user) {
|
||||
return Number(user && user.id) !== currentUserId;
|
||||
})
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
? readArrayField(req.body, ['permission_keys[]', 'permission_keys'])
|
||||
: [];
|
||||
const selectedUserIds = shouldSyncUsers
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
|
||||
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
|
||||
|
||||
if (!name) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
|
||||
}
|
||||
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
let availableUsers = [];
|
||||
if (shouldSyncUsers) {
|
||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
}
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
}
|
||||
if (shouldSyncUsers) {
|
||||
await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: [];
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
|
||||
if (normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
|
||||
? req.body['user_ids[]']
|
||||
: req.body.user_ids
|
||||
? [].concat(req.body.user_ids)
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
|
||||
if (normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
if (String(role.role_key || '') === 'administrators') {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
||||
}
|
||||
if (Number(role.user_count) > 0) {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
|
||||
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
|
||||
function mapRolesForForm(roles, selectedRoleIds) {
|
||||
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
}));
|
||||
|
||||
return (Array.isArray(roles) ? roles : []).map(function (role) {
|
||||
return Object.assign({}, role, {
|
||||
isSelected: selectedIds.has(Number(role.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds) {
|
||||
const availableRoles = await fetchRoleOptions();
|
||||
const validRoleIds = new Set(availableRoles.map(function (role) {
|
||||
return Number(role.id);
|
||||
}));
|
||||
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
if (!normalizedRoleIds.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
if (normalizedRoleIds.some(function (roleId) {
|
||||
return !validRoleIds.has(roleId);
|
||||
})) {
|
||||
return { ok: false, message: 'One or more selected roles are invalid.' };
|
||||
}
|
||||
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
const mappedUsers = users.map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
});
|
||||
});
|
||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/users/new', requirePermission('users.create'), function (req, res) {
|
||||
fetchRoleOptions().then(function (roles) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||
}).catch(function (error) {
|
||||
res.status(500).send(error.message || 'Unable to load roles.');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
isCurrentUser: false,
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
username: username,
|
||||
name: name
|
||||
};
|
||||
|
||||
async function renderValidationError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return renderValidationError('Name is required.');
|
||||
}
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
}
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'users', name)) {
|
||||
return renderValidationError('That name already exists.');
|
||||
}
|
||||
|
||||
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
res.redirect('/users?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
if (!roleCheck.ok) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||
}
|
||||
|
||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (!name) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
|
||||
}
|
||||
|
||||
const [userRows] = await pool.query('SELECT id, username FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const username = String(req.body.username || userRows[0].username || '').trim();
|
||||
if (!username) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, userId)) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user