Release v2.4.2
This commit is contained in:
@@ -1,41 +1,8 @@
|
||||
// API source add page renderer and form defaults.
|
||||
// API source add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildApiSourceFormViewModel } = require('./form-view-model');
|
||||
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
authMethod: 'none',
|
||||
authUsername: '',
|
||||
authPassword: '',
|
||||
authBearerToken: '',
|
||||
authHeaderName: 'X-API-Key',
|
||||
authHeaderValue: '',
|
||||
itemsPath: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', {
|
||||
title: isEdit ? 'Edit API source' : 'Add API source',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewApiSource.lastPullError || ''
|
||||
});
|
||||
module.exports = function renderApiSourceAddPage(apiSource, message, currentUser, options) {
|
||||
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, options, false));
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// API source edit page renderer that reuses the add form.
|
||||
// API source edit page renderer.
|
||||
|
||||
const renderApiSourceFormPage = require('./add');
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildApiSourceFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
@@ -8,7 +9,7 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
|
||||
lastPullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderApiSourceFormPage(Object.assign({}, apiSource, {
|
||||
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(Object.assign({}, apiSource, {
|
||||
lastResponseJson: viewData.lastResponseJson,
|
||||
lastPullError: viewData.lastPullError,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
@@ -18,5 +19,5 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || ''
|
||||
}), 'edit', message, currentUser);
|
||||
}), message, currentUser, viewData, true));
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
// Shared API source form view-model builder.
|
||||
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
authMethod: 'none',
|
||||
authUsername: '',
|
||||
authPassword: '',
|
||||
authBearerToken: '',
|
||||
authHeaderName: 'X-API-Key',
|
||||
authHeaderValue: '',
|
||||
itemsPath: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: ''
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiSourceFormViewModel(apiSource, message, currentUser, options, isEdit) {
|
||||
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
|
||||
const viewOptions = options || {};
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit API source' : 'Add API source',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
|
||||
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
|
||||
formAttrs: isEdit && viewApiSource.id ? 'data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/' + viewApiSource.id + '/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' : 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/api-sources/new"',
|
||||
cancelUrl: '/data-sources/api-sources',
|
||||
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildApiSourceFormViewModel: buildApiSourceFormViewModel
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
// API source route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderApiSourcesPage = require('./list');
|
||||
const renderApiSourceAddPage = require('./add');
|
||||
const renderApiSourceEditPage = require('./edit');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const apiSourceIds = 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, 'source_id')) {
|
||||
const sourceId = Number(value.source_id);
|
||||
if (Number.isFinite(sourceId)) {
|
||||
apiSourceIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return apiSourceIds;
|
||||
}
|
||||
|
||||
async function isApiSourceInUse(pool, common, apiSourceId) {
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
return usageIds.has(Number(apiSourceId));
|
||||
}
|
||||
|
||||
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 = 'api-source-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const dataSourceTasks = deps.dataSourceTasks;
|
||||
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/api-sources', requirePermission('api-sources.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.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||
inUse: usageIds.has(Number(apiSource.id))
|
||||
});
|
||||
});
|
||||
|
||||
res.send(renderApiSourcesPage({
|
||||
apiSources: apiSources,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
||||
}, 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(renderApiSourceAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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, 'source_id') && Number(value.source_id) === Number(apiSource.id)) {
|
||||
return true;
|
||||
}
|
||||
Object.keys(value).forEach(function (key) {
|
||||
stack.push(value[key]);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
res.send(renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
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 || '',
|
||||
inUse: inUse
|
||||
}), {
|
||||
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, next) {
|
||||
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
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, 'i_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 i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, 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.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, 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.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, 'i_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 i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, 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.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, 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
|
||||
}
|
||||
});
|
||||
redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
|
||||
closeUrl: '/data-sources/api-sources',
|
||||
newUrl: '/data-sources/api-sources/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/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');
|
||||
}
|
||||
|
||||
if (await isApiSourceInUse(pool, common, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('This API source is still used by one or more slides.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
// Data-source route entry point that re-exports the admin handlers.
|
||||
|
||||
module.exports = require('../admin/data-sources');
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
// RSS feed add page renderer and form defaults.
|
||||
// RSS feed add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRssFeedFormViewModel } = require('./form-view-model');
|
||||
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
itemLimit: 1
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
|
||||
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeed: viewRssFeed,
|
||||
inUse: Boolean(viewRssFeed.inUse),
|
||||
pulledItems: viewRssFeed.pulledItems || [],
|
||||
pullError: viewRssFeed.pullError || ''
|
||||
});
|
||||
module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) {
|
||||
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false));
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// RSS feed edit page renderer that reuses the add form.
|
||||
// RSS feed edit page renderer.
|
||||
|
||||
const renderRssFeedFormPage = require('./add');
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRssFeedFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
@@ -8,8 +9,8 @@ module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentU
|
||||
pullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
|
||||
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(Object.assign({}, rssFeed, {
|
||||
pulledItems: viewData.pulledItems,
|
||||
pullError: viewData.pullError
|
||||
}), 'edit', message, currentUser);
|
||||
}), message, currentUser, viewData, true));
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
// Shared RSS feed form view-model builder.
|
||||
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
itemLimit: 1
|
||||
};
|
||||
}
|
||||
|
||||
function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdit) {
|
||||
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
|
||||
const viewOptions = options || {};
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeed: viewRssFeed,
|
||||
inUse: Boolean(viewRssFeed.inUse),
|
||||
pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [],
|
||||
pullError: viewOptions.pullError || viewRssFeed.pullError || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: !isEdit || Boolean(viewRssFeed.inUse),
|
||||
formAction: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id : '/data-sources/rss-feeds',
|
||||
formAttrs: isEdit && viewRssFeed.id ? 'data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/' + viewRssFeed.id + '/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new"' : 'data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/rss-feeds/new"',
|
||||
cancelUrl: '/data-sources/rss-feeds',
|
||||
deleteUrl: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id + '/delete' : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildRssFeedFormViewModel: buildRssFeedFormViewModel
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
// RSS feed route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderRssFeedsPage = require('./list');
|
||||
const renderRssFeedAddPage = require('./add');
|
||||
const renderRssFeedEditPage = require('./edit');
|
||||
|
||||
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'), function (req, res) {
|
||||
res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
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/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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
// Schedule group add/edit renderer and defaults.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultScheduleGroup() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
shortDescription: ''
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTimeLocalValue(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const year = String(date.getFullYear()).padStart(4, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
module.exports = function renderScheduleGroupFormPage(scheduleGroup, scheduleEntries, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewScheduleGroup = Object.assign(buildDefaultScheduleGroup(), scheduleGroup || {});
|
||||
const viewScheduleEntries = Array.isArray(scheduleEntries) && scheduleEntries.length
|
||||
? scheduleEntries.map(function (entry) {
|
||||
return Object.assign({}, entry, {
|
||||
startValue: formatDateTimeLocalValue(entry.start_datetime),
|
||||
endValue: formatDateTimeLocalValue(entry.end_datetime)
|
||||
});
|
||||
})
|
||||
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
|
||||
|
||||
return renderView('data-sources/schedules/form', {
|
||||
title: isEdit ? 'Edit schedule group' : 'Add schedule group',
|
||||
active: 'schedules',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
isEdit: isEdit,
|
||||
scheduleGroup: viewScheduleGroup,
|
||||
scheduleEntries: viewScheduleEntries,
|
||||
inUse: Boolean(viewScheduleGroup.inUse),
|
||||
assetVersion: Date.now().toString(36)
|
||||
});
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
// Schedule group list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatNextStartLabel(value, formatDashboardDate) {
|
||||
if (!value) {
|
||||
return 'No entries';
|
||||
}
|
||||
|
||||
const label = typeof formatDashboardDate === 'function'
|
||||
? formatDashboardDate(value)
|
||||
: String(value);
|
||||
return label || 'No entries';
|
||||
}
|
||||
|
||||
module.exports = function renderScheduleGroupsPage(data, message, currentUser, formatDashboardDate) {
|
||||
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
|
||||
return Object.assign({}, scheduleGroup, {
|
||||
nextStartLabel: formatNextStartLabel(scheduleGroup.next_start_datetime, formatDashboardDate),
|
||||
nextStartValue: scheduleGroup.next_start_datetime ? new Date(scheduleGroup.next_start_datetime).toISOString() : ''
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('data-sources/schedules/list', {
|
||||
title: 'Schedules',
|
||||
active: 'schedules',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scheduleGroups: scheduleGroups,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Timetable group add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false));
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Timetable group edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderTimetableGroupEditPage(timetableGroup, timetableEntries, message, currentUser) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, true));
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// Shared timetable group form view-model builder.
|
||||
|
||||
function buildDefaultTimetableGroup() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
shortDescription: ''
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTimeLocalValue(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const year = String(date.getFullYear()).padStart(4, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit) {
|
||||
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
|
||||
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
|
||||
? timetableEntries.map(function (entry) {
|
||||
return Object.assign({}, entry, {
|
||||
startValue: formatDateTimeLocalValue(entry.start_datetime),
|
||||
endValue: formatDateTimeLocalValue(entry.end_datetime)
|
||||
});
|
||||
})
|
||||
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit timetable group' : 'Add timetable group',
|
||||
active: 'timetables',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
isEdit: Boolean(isEdit),
|
||||
timetableGroup: viewTimetableGroup,
|
||||
timetableEntries: viewTimetableEntries,
|
||||
inUse: Boolean(viewTimetableGroup.inUse),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: !isEdit || Boolean(viewTimetableGroup.inUse),
|
||||
formAction: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id : '/data-sources/timetables',
|
||||
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/data-sources/timetables"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/data-sources/timetables/new"',
|
||||
cancelUrl: '/data-sources/timetables',
|
||||
deleteUrl: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id + '/delete' : '',
|
||||
assetVersion: Date.now().toString(36)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTimetableGroupFormViewModel: buildTimetableGroupFormViewModel
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// Timetable group list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatNextStartLabel(value, formatDashboardDate) {
|
||||
if (!value) {
|
||||
return 'No entries';
|
||||
}
|
||||
|
||||
const label = typeof formatDashboardDate === 'function'
|
||||
? formatDashboardDate(value)
|
||||
: String(value);
|
||||
return label || 'No entries';
|
||||
}
|
||||
|
||||
module.exports = function renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) {
|
||||
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
|
||||
return Object.assign({}, timetableGroup, {
|
||||
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate),
|
||||
nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : ''
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('data-sources/timetables/list', {
|
||||
title: 'Timetables',
|
||||
active: 'timetables',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
timetableGroups: timetableGroups,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
// Timetable route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderTimetableGroupsPage = require('./list');
|
||||
const renderTimetableGroupAddPage = require('./add');
|
||||
const renderTimetableGroupEditPage = require('./edit');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const timetableGroupIds = 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, 'schedule_group_id')) {
|
||||
const timetableGroupId = Number(value.schedule_group_id);
|
||||
if (Number.isFinite(timetableGroupId)) {
|
||||
timetableGroupIds.add(timetableGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return timetableGroupIds;
|
||||
}
|
||||
|
||||
function readScheduleArrayFieldValue(body, keys) {
|
||||
const searchKeys = Array.isArray(keys) ? keys : [keys];
|
||||
for (let index = 0; index < searchKeys.length; index += 1) {
|
||||
const key = searchKeys[index];
|
||||
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value !== undefined && value !== null) {
|
||||
return [value];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildScheduleEntryRows(req, parseDateTimeLocal) {
|
||||
const body = req.body || {};
|
||||
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
|
||||
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
|
||||
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
|
||||
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
|
||||
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
|
||||
|
||||
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
|
||||
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
|
||||
const error = new Error('Schedule entry data is invalid.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (let index = 0; index < titles.length; index += 1) {
|
||||
const idValue = Number(ids[index]);
|
||||
const title = String(titles[index] || '').trim();
|
||||
const shortDescription = String(descriptions[index] || '').trim();
|
||||
const startDatetime = parseDateTimeLocal(starts[index]);
|
||||
const endDatetime = parseDateTimeLocal(ends[index]);
|
||||
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
|
||||
|
||||
if (!hasContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
const error = new Error('Each schedule entry requires a title.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!startDatetime) {
|
||||
const error = new Error('Each schedule entry requires a start datetime.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (endDatetime && endDatetime < startDatetime) {
|
||||
const error = new Error('Schedule entry end datetime must be after the start datetime.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
|
||||
title: title,
|
||||
shortDescription: shortDescription,
|
||||
startDatetime: startDatetime,
|
||||
endDatetime: endDatetime
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
module.exports = function registerTimetableRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const parseDateTimeLocal = deps.parseDateTimeLocal;
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/data-sources/timetables', requirePermission('timetables.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.fetchTimetableGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
|
||||
return Object.assign({}, timetableGroup, {
|
||||
inUse: usageIds.has(Number(timetableGroup.id))
|
||||
});
|
||||
});
|
||||
|
||||
res.send(renderTimetableGroupsPage({
|
||||
timetableGroups: timetableGroups,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Timetables', 'Timetable groups')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/timetables/new', requirePermission('timetables.create'), function (req, res) {
|
||||
res.send(renderTimetableGroupAddPage(null, [], req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/data-sources/timetables/:id/edit', requirePermission('timetables.update'), async function (req, res, next) {
|
||||
try {
|
||||
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
|
||||
if (!timetableGroup) {
|
||||
return res.status(404).send('Timetable group not found');
|
||||
}
|
||||
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const timetableEntries = typeof common.fetchTimetableEntriesByGroupId === 'function'
|
||||
? await common.fetchTimetableEntriesByGroupId(pool, timetableGroup.id)
|
||||
: [];
|
||||
|
||||
res.send(renderTimetableGroupEditPage(Object.assign({}, timetableGroup, {
|
||||
shortDescription: timetableGroup.short_description || '',
|
||||
inUse: usageIds.has(Number(timetableGroup.id))
|
||||
}), timetableEntries, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/timetables', requirePermission('timetables.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildTimetableGroupPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
|
||||
return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.'));
|
||||
}
|
||||
|
||||
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
|
||||
[payload.name, payload.shortDescription || null, actorId, actorId]
|
||||
);
|
||||
|
||||
if (entryRows.length) {
|
||||
const insertRows = entryRows.map(function (entry) {
|
||||
return [
|
||||
result.insertId,
|
||||
entry.title,
|
||||
entry.shortDescription || null,
|
||||
entry.startDatetime,
|
||||
entry.endDatetime || null,
|
||||
actorId,
|
||||
actorId
|
||||
];
|
||||
});
|
||||
|
||||
await connection.query(
|
||||
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
|
||||
[insertRows]
|
||||
);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
redirectAfterSave(req, res, '/data-sources/timetables/' + result.insertId + '/edit', {
|
||||
closeUrl: '/data-sources/timetables',
|
||||
newUrl: '/data-sources/timetables/new',
|
||||
message: 'Timetable group created.'
|
||||
});
|
||||
} 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/timetables/:id', requirePermission('timetables.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
|
||||
if (!timetableGroup) {
|
||||
return res.status(404).send('Timetable group not found');
|
||||
}
|
||||
|
||||
const payload = common.buildTimetableGroupPayload(req, timetableGroup);
|
||||
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) {
|
||||
return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.'));
|
||||
}
|
||||
|
||||
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.shortDescription || null, actorId, timetableGroup.id]
|
||||
);
|
||||
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]);
|
||||
|
||||
if (entryRows.length) {
|
||||
const insertRows = entryRows.map(function (entry) {
|
||||
return [
|
||||
timetableGroup.id,
|
||||
entry.title,
|
||||
entry.shortDescription || null,
|
||||
entry.startDatetime,
|
||||
entry.endDatetime || null,
|
||||
actorId,
|
||||
actorId
|
||||
];
|
||||
});
|
||||
|
||||
await connection.query(
|
||||
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
|
||||
[insertRows]
|
||||
);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
redirectAfterSave(req, res, '/data-sources/timetables/' + timetableGroup.id + '/edit', {
|
||||
closeUrl: '/data-sources/timetables',
|
||||
newUrl: '/data-sources/timetables/new',
|
||||
message: 'Timetable group updated.'
|
||||
});
|
||||
} 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/timetables/:id/delete', requirePermission('timetables.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
|
||||
if (!timetableGroup) {
|
||||
return res.status(404).send('Timetable group not found');
|
||||
}
|
||||
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
if (usageIds.has(Number(timetableGroup.id))) {
|
||||
return res.redirect('/data-sources/timetables?message=' + encodeURIComponent('This timetable group is still used by one or more slides.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/timetables?message=' + encodeURIComponent('Timetable group deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user