// RSS feed route registration. const { buildPagination } = require('../../../lib/pagination'); const renderRssFeedsPage = require('./list'); const renderRssFeedAddPage = require('./add'); const renderRssFeedEditPage = require('./edit'); const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate'); const { fetchAppSettings } = require('#src/data/app-settings'); async function getDataSourceUsageMaps(pool, common) { const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); const rssFeedIds = new Set(); for (let index = 0; index < slides.length; index += 1) { const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; if (!content || typeof content !== 'object') { continue; } (function walk(value) { if (!value || typeof value !== 'object') { return; } if (Array.isArray(value)) { value.forEach(walk); return; } if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) { const feedId = Number(value.feed_id); if (Number.isFinite(feedId)) { rssFeedIds.add(feedId); } } Object.keys(value).forEach(function (key) { walk(value[key]); }); })(content); } return rssFeedIds; } async function isRssFeedInUse(pool, common, rssFeedId) { const usageIds = await getDataSourceUsageMaps(pool, common); return usageIds.has(Number(rssFeedId)); } async function sendRefreshTaskState(req, res, dataSourceTasks, 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 = await dataSourceTasks.getTaskStatusById(taskId); const expectedKey = 'rss-feed-refresh:' + Number(sourceId); if (!task || task.key !== expectedKey) { return res.status(404).json({ error: 'Refresh task not found.' }); } res.json(task); } module.exports = function registerRssFeedRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; const dataSourceTasks = deps.dataSourceTasks; const backgroundTaskQueue = deps.backgroundTaskQueue; const getAuditUserId = deps.getAuditUserId; const redirectAfterSave = deps.redirectAfterSave; const formatDashboardDate = deps.formatDashboardDate || function (value) { return value ? String(value) : ''; }; const requirePermission = deps.requirePermission; const LIST_PAGE_SIZE = 25; app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const search = common.getSearchQuery(req); const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); const usageIds = await getDataSourceUsageMaps(pool, common); const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) { return Object.assign({}, rssFeed, { inUse: usageIds.has(Number(rssFeed.id)) }); }); res.send(renderRssFeedsPage({ rssFeeds: rssFeeds, pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), async function (req, res, next) { try { const settings = await fetchAppSettings(pool); res.send(renderRssFeedAddPage({ updateIntervalValue: settings['data-sources.rss_default_interval_value'], updateIntervalUnit: settings['data-sources.rss_default_interval_unit'] }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); 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 [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); const inUse = (slides || []).some(function (row) { const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null; if (!content || typeof content !== 'object') { return false; } const stack = [content]; while (stack.length) { const value = stack.pop(); if (!value || typeof value !== 'object') { continue; } if (Array.isArray(value)) { value.forEach(function (item) { stack.push(item); }); continue; } if (Object.prototype.hasOwnProperty.call(value, 'feed_id') && Number(value.feed_id) === Number(rssFeed.id)) { return true; } Object.keys(value).forEach(function (key) { stack.push(value[key]); }); } return false; }); const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id) : []; res.send(renderRssFeedEditPage(Object.assign({}, rssFeed, { feedUrl: rssFeed.feed_url, updateIntervalValue: rssFeed.update_interval_value, updateIntervalUnit: rssFeed.update_interval_unit || 'minutes', itemLimit: rssFeed.item_limit, inUse: inUse }), { pulledItems: pulledItems, pullError: '' }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); app.get('/data-sources/rss-feeds/:id/duplicate', requirePermission('rss-feeds.read'), requirePermission('rss-feeds.create'), 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'); } let duplicateName = buildDuplicateRssFeedName(rssFeed.name); let duplicateIndex = 2; while (await common.fetchDuplicateName(pool, 'i_rss_feeds', duplicateName)) { duplicateName = buildDuplicateRssFeedName(rssFeed.name) + ' (' + duplicateIndex + ')'; duplicateIndex += 1; } res.send(renderRssFeedAddPage(buildDuplicateRssFeed(rssFeed, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, { messageVariant: 'info', showSaveSecondaryActions: true })); } catch (error) { next(error); } }); app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) { sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next); }); 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, 'i_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 i_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(); dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { return dataSourceTasks.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.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, 'i_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 i_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(); dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { return dataSourceTasks.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 } }); redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), { closeUrl: '/data-sources/rss-feeds', newUrl: '/data-sources/rss-feeds/new', message: message }); } 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'); } if (await isRssFeedInUse(pool, common, rssFeed.id)) { return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by one or more slides.')); } const connection = await pool.getConnection(); try { await connection.beginTransaction(); await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]); await connection.commit(); dataSourceTasks.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); } }); };