// 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); } }); };