// Weather location source CRUD routes. const { buildPagination } = require('../../lib/pagination'); const renderWeatherLocationsPage = require('./weather/list'); const renderWeatherLocationAddPage = require('./weather/add'); const renderWeatherLocationEditPage = require('./weather/edit'); const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('./weather/duplicate'); const { fetchAppSettings } = require('../../../data/app-settings'); const { buildAuditChanges } = require('../../../data/audit-log'); async function getWeatherLocationUsageIds(pool, common) { const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); const weatherLocationIds = new Set(); slides.forEach(function (slide) { const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null; if (!content || typeof content !== 'object') return; (function walk(value) { if (!value || typeof value !== 'object') return; if (Array.isArray(value)) { value.forEach(walk); return; } if (Object.prototype.hasOwnProperty.call(value, 'weather_location_id')) { const locationId = Number(value.weather_location_id); if (Number.isFinite(locationId)) weatherLocationIds.add(locationId); } Object.keys(value).forEach(function (key) { walk(value[key]); }); }(content)); }); return weatherLocationIds; } async function isWeatherLocationInUse(pool, common, locationId) { const usageIds = await getWeatherLocationUsageIds(pool, common); return usageIds.has(Number(locationId)); } module.exports = function registerWeatherRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const getAuditUserId = deps.getAuditUserId; const redirectAfterSave = deps.redirectAfterSave; const dataSourceTasks = deps.dataSourceTasks; const backgroundTaskQueue = deps.backgroundTaskQueue; const requirePermission = deps.requirePermission; const recordRequestAuditEvent = deps.recordRequestAuditEvent; const listPageSize = 25; async function getProviderAvailability() { const settings = await fetchAppSettings(pool); return { openMeteo: true, pirateWeather: Boolean(String(settings['weather.pirate_weather_api_key'] || '').trim()) }; } app.get('/data-sources/weather', requirePermission('weather.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.fetchWeatherLocationsPage(pool, page, listPageSize, search, sort, direction); const usageIds = await getWeatherLocationUsageIds(pool, common); res.send(renderWeatherLocationsPage({ weatherLocations: data.rows.map(function (location) { return Object.assign({}, location, { inUse: usageIds.has(Number(location.id)) }); }), pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search, sort, direction }, listPageSize, 'weather locations', 'weather location pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); app.get('/data-sources/weather/new', requirePermission('weather.create'), function (req, res, next) { getProviderAvailability().then(function (providerAvailability) { res.send(renderWeatherLocationAddPage({}, req.query.message ? String(req.query.message) : '', req.currentUser, { providerAvailability: providerAvailability })); }).catch(next); }); app.get('/data-sources/weather/geocode', requirePermission('weather.read'), async function (req, res, next) { try { const results = await common.fetchWeatherLocationSuggestions(req.query.q); res.json({ results: results }); } catch (error) { next(error); } }); app.get('/data-sources/weather/:id/edit', requirePermission('weather.update'), async function (req, res, next) { try { const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); if (!location) return res.status(404).send('Weather location not found'); const providerAvailability = await getProviderAvailability(); const inUse = await isWeatherLocationInUse(pool, common, location.id); res.send(renderWeatherLocationEditPage(Object.assign({}, location, { inUse: inUse }), req.query.message ? String(req.query.message) : '', req.currentUser, { providerAvailability: providerAvailability })); } catch (error) { next(error); } }); app.get('/data-sources/weather/:id/duplicate', requirePermission('weather.read'), requirePermission('weather.create'), async function (req, res, next) { try { const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); if (!location) return res.status(404).send('Weather location not found'); let duplicateName = buildDuplicateWeatherLocationName(location.name); let duplicateIndex = 2; while (await common.fetchDuplicateName(pool, 'i_weather_locations', duplicateName)) { duplicateName = buildDuplicateWeatherLocationName(location.name) + ' (' + duplicateIndex + ')'; duplicateIndex += 1; } res.send(renderWeatherLocationAddPage(buildDuplicateWeatherLocation(location, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, { providerAvailability: await getProviderAvailability(), messageVariant: 'info', showSaveSecondaryActions: true })); } catch (error) { next(error); } }); app.post('/data-sources/weather', requirePermission('weather.create'), async function (req, res, next) { const connection = await pool.getConnection(); try { const payload = common.buildWeatherLocationPayload(req, null); if (await common.fetchDuplicateName(pool, 'i_weather_locations', payload.name)) { return res.redirect('/data-sources/weather/new?message=' + encodeURIComponent('A weather location with that name already exists.')); } const actorId = getAuditUserId(req); await connection.beginTransaction(); const [result] = await connection.query('INSERT INTO i_weather_locations (name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, update_interval_value, update_interval_unit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [payload.name, payload.locationLabel, payload.latitude, payload.longitude, payload.timezone, payload.provider, payload.temperatureUnit, payload.windUnit, payload.precipitationUnit, payload.updateIntervalValue, payload.updateIntervalUnit, actorId, actorId]); await connection.commit(); dataSourceTasks.registerRecurringRefresh('weather-location', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { return dataSourceTasks.refreshWeatherLocationInBackground(result.insertId, null); }); await backgroundTaskQueue.enqueueTask({ key: 'weather-location-refresh:' + result.insertId, title: 'Weather location refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'weather-location', sourceId: result.insertId, sourceName: payload.name, actorId: actorId }, metadata: { sourceType: 'weather-location', sourceId: result.insertId, sourceName: payload.name } }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'weather', eventType: 'weather-location.created', actorUserId: actorId, targetType: 'weather-location', targetId: result.insertId, targetLabel: payload.name }); redirectAfterSave(req, res, '/data-sources/weather/' + result.insertId + '/edit', { closeUrl: '/data-sources/weather', newUrl: '/data-sources/weather/new', message: 'Weather location created.' }); } catch (error) { try { await connection.rollback(); } catch (_rollbackError) { } next(error); } finally { connection.release(); } }); app.post('/data-sources/weather/:id', requirePermission('weather.update'), async function (req, res, next) { const connection = await pool.getConnection(); try { const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); if (!location) return res.status(404).send('Weather location not found'); if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') { const enabled = location.enabled === 0 || location.enabled === false ? 1 : 0; await connection.query('UPDATE i_weather_locations SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), location.id]); if (enabled) { dataSourceTasks.registerRecurringRefresh('weather-location', location.id, location.name, location.update_interval_value, location.update_interval_unit, function () { return dataSourceTasks.refreshWeatherLocationInBackground(location.id, getAuditUserId(req)); }); } else { dataSourceTasks.removeRecurringRefresh('weather-location', location.id); } if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'weather', eventType: enabled ? 'weather-location.enabled' : 'weather-location.disabled', actorUserId: getAuditUserId(req), targetType: 'weather-location', targetId: location.id, targetLabel: location.name }); return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent(enabled ? 'Weather location enabled.' : 'Weather location disabled.')); } const payload = common.buildWeatherLocationPayload(req, location); if (await common.fetchDuplicateName(pool, 'i_weather_locations', payload.name, location.id)) { return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('A weather location with that name already exists.')); } await connection.beginTransaction(); await connection.query('UPDATE i_weather_locations SET name = ?, location_label = ?, latitude = ?, longitude = ?, timezone = ?, provider = ?, temperature_unit = ?, wind_unit = ?, precipitation_unit = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ?', [payload.name, payload.locationLabel, payload.latitude, payload.longitude, payload.timezone, payload.provider, payload.temperatureUnit, payload.windUnit, payload.precipitationUnit, payload.updateIntervalValue, payload.updateIntervalUnit, getAuditUserId(req), location.id]); await connection.commit(); if (location.enabled === 0 || location.enabled === false) { dataSourceTasks.removeRecurringRefresh('weather-location', location.id); } else { dataSourceTasks.registerRecurringRefresh('weather-location', location.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { return dataSourceTasks.refreshWeatherLocationInBackground(location.id, null); }); } if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'weather', eventType: 'weather-location.updated', actorUserId: getAuditUserId(req), targetType: 'weather-location', targetId: location.id, targetLabel: payload.name, details: { changes: buildAuditChanges({ name: location.name, locationLabel: location.location_label, latitude: location.latitude, longitude: location.longitude, timezone: location.timezone, provider: location.provider, temperatureUnit: location.temperature_unit, windUnit: location.wind_unit, precipitationUnit: location.precipitation_unit, updateIntervalValue: location.update_interval_value, updateIntervalUnit: location.update_interval_unit }, payload) } }); redirectAfterSave(req, res, '/data-sources/weather/' + location.id + '/edit', { closeUrl: '/data-sources/weather', newUrl: '/data-sources/weather/new', message: 'Weather location updated.' }); } catch (error) { try { await connection.rollback(); } catch (_rollbackError) { } next(error); } finally { connection.release(); } }); app.post('/data-sources/weather/:id/refresh', requirePermission('weather.allow'), async function (req, res, next) { try { const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); if (!location) return res.status(404).send('Weather location not found'); if (location.enabled === 0 || location.enabled === false) { return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('Weather location is disabled.')); } await backgroundTaskQueue.enqueueTask({ key: 'weather-location-refresh:' + location.id, title: 'Weather location refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'weather-location', sourceId: location.id, sourceName: location.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'weather-location', sourceId: location.id, sourceName: location.name } }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'weather', eventType: 'weather-location.refresh_requested', actorUserId: getAuditUserId(req), targetType: 'weather-location', targetId: location.id, targetLabel: location.name }); res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('Weather refresh queued.')); } catch (error) { next(error); } }); app.post('/data-sources/weather/:id/delete', requirePermission('weather.delete'), async function (req, res, next) { try { const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); if (!location) return res.status(404).send('Weather location not found'); if (await isWeatherLocationInUse(pool, common, location.id)) { return res.redirect('/data-sources/weather?message=' + encodeURIComponent('This weather location is still used by one or more slides.')); } await pool.query('DELETE FROM i_weather_locations WHERE id = ?', [location.id]); dataSourceTasks.removeRecurringRefresh('weather-location', location.id); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'weather', eventType: 'weather-location.deleted', actorUserId: getAuditUserId(req), targetType: 'weather-location', targetId: location.id, targetLabel: location.name }); res.redirect('/data-sources/weather?message=' + encodeURIComponent('Weather location deleted.')); } catch (error) { next(error); } }); };