From 9479b122fa87e2623bce2545ae15f1268cba32c6 Mon Sep 17 00:00:00 2001 From: Skylar Grant Date: Thu, 15 Oct 2026 21:04:22 -0400 Subject: [PATCH] Save per-user settings on the server Each user's sites, groups, technicians, timing, weather location and hidden panels/columns are saved by Zoho email in DATA_DIR, merged over the .env defaults and validated on PUT /api/settings. /api/names suggests names from recent tickets, and weather is cached per zip from a fixed list of campuses. Co-Authored-By: Claude Opus 5.5 --- src/names.js | 63 +++++++++++++++++++++ src/places.js | 16 ++++++ src/routes.js | 83 +++++++++++++++++++++------- src/server.js | 53 ++++++++++++++---- src/settings.js | 143 ++++++++++++++++++++++++++++++++++++++++++++++++ src/store.js | 38 +++++++++++++ src/tickets.js | 40 ++++++++++++-- src/weather.js | 46 ++++++++-------- 8 files changed, 422 insertions(+), 60 deletions(-) create mode 100644 src/names.js create mode 100644 src/places.js create mode 100644 src/settings.js create mode 100644 src/store.js diff --git a/src/names.js b/src/names.js new file mode 100644 index 0000000..0a2fc8e --- /dev/null +++ b/src/names.js @@ -0,0 +1,63 @@ +const path = require('path'); +const { createJsonFile } = require('./store'); +const { fetchRecentNames } = require('./tickets'); + +const REFRESH_HOURS = 1; // SDP is asked for fresh names at most this often, whoever asks +const LOOKBACK_DAYS = 30; +const PAGES = 5; // 500 tickets is plenty to find the active technicians and sites +const MAX_NAMES = 500; // per list, so the file can't grow without end + +const FIELDS = ['sites', 'groups', 'technicians']; + +// Names seen in SDP, shared by all users and kept across restarts, so suggestions build up +// over time. Typing a name by hand always works; this only saves people from typos. +function createNameCache({ dataDir, seed }) { + const file = createJsonFile(path.join(dataDir, 'names.json'), { sites: [], groups: [], technicians: [], fetchedAt: 0 }); + let pending = null; + + function merge(found) { + const value = file.get(); + for (const field of FIELDS) { + const byLower = new Map(value[field].map(n => [n.toLowerCase(), n])); + for (const name of found[field] || []) { + if (!byLower.has(name.toLowerCase())) byLower.set(name.toLowerCase(), name); + } + value[field] = [...byLower.values()].sort((a, b) => a.localeCompare(b)).slice(0, MAX_NAMES); + } + } + + // the user's token is used to refresh; if SDP says no, the cached names are still returned + async function list(accessToken) { + if (Date.now() - file.get().fetchedAt > REFRESH_HOURS * 60 * 60 * 1000) { + pending = pending || fetchRecentNames({ accessToken, days: LOOKBACK_DAYS, pages: PAGES }) + .then(found => { + merge(found); + file.get().fetchedAt = Date.now(); + return file.save(); + }) + .catch(err => { + if (err.response?.status === 401) throw err; + console.error('Could not refresh name suggestions:', err.response?.data || err.message); + }) + .finally(() => { pending = null; }); + await pending; + } + + const value = file.get(); + merge(seed()); + return Object.fromEntries(FIELDS.map(f => [f, value[f]])); + } + + // a typed name that differs from SDP only in case is stored with SDP's spelling, since the + // technician filters compare exactly + function canonical(field, names) { + const byLower = new Map(file.get()[field].map(n => [n.toLowerCase(), n])); + return names.map(n => byLower.get(n.toLowerCase()) || n); + } + + return { list, canonical }; +} + +module.exports = { + createNameCache +}; diff --git a/src/places.js b/src/places.js new file mode 100644 index 0000000..4a47fc5 --- /dev/null +++ b/src/places.js @@ -0,0 +1,16 @@ +// Weather locations people can pick from in settings. Keeping it to a fixed list means the +// browser can't make the server look up arbitrary places, and the weather cache stays small. +// Add satellites here as needed; zip is a US zip code. +module.exports = [ + { zip: '04330', label: 'MCCS System Office (Augusta)' }, + { zip: '04210', label: 'CMCC (Auburn)' }, + { zip: '04543', label: 'CMCC Damariscotta' }, + { zip: '04401', label: 'EMCC (Bangor)' }, + { zip: '04937', label: 'KVCC (Fairfield)' }, + { zip: '04944', label: 'KVCC Alfond Campus (Hinckley)' }, + { zip: '04769', label: 'NMCC (Presque Isle)' }, + { zip: '04106', label: 'SMCC (South Portland)' }, + { zip: '04011', label: 'SMCC Midcoast (Brunswick)' }, + { zip: '04619', label: 'WCCC (Calais)' }, + { zip: '04090', label: 'YCCC (Wells)' } +]; diff --git a/src/routes.js b/src/routes.js index ddb371a..68ffd47 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,5 +1,6 @@ const express = require('express'); const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets'); +const { LIMITS, PANELS, SettingsError } = require('./settings'); // Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account. function createAuthRoutes({ zoho, sessions }) { @@ -45,9 +46,10 @@ function createAuthRoutes({ zoho, sessions }) { return router; } -// Data: each call runs against SDP with the caller's own token. Nothing is cached per user, -// so the browser decides how often to ask (see checkMinutes and workloadMinutes). -function createApiRoutes({ sessions, weatherService, settings, timing }) { +// Data: each call runs against SDP with the caller's own token and their saved settings. +// Nothing is cached per user, so the browser decides how often to ask (see checkMinutes and +// the user's workloadMinutes). +function createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }) { const router = express.Router(); router.use((req, res, next) => { @@ -67,26 +69,67 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) { } }; + // everything the settings panel needs, sent with the session and after each save + const settingsReply = user => ({ + settings: userSettings.forUser(user), + settingsSaved: userSettings.hasSaved(user), + canSaveSettings: userSettings.canSave(user) + }); + router.get('/session', (req, res) => { res.json({ user: req.session.user, sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000, - // the region filters every query uses; a future settings panel would start from these - settings: { - sites: settings.scope.sites, - groups: settings.scope.groups, - technicians: settings.technicians - }, + ...settingsReply(req.session.user), + settingsLimits: { ...LIMITS, panels: PANELS }, + places: weatherService.enabled ? places : [], checkMinutes: timing.checkMinutes, - windowMinutes: timing.windowMinutes, - workloadMinutes: timing.workloadMinutes, - feedHours: timing.feedHours + windowMinutes: timing.windowMinutes }); }); + // only JSON bodies; a cross-site form can't send one without a CORS preflight we never answer + const readJson = express.json({ limit: '32kb' }); + const jsonBody = (req, res, next) => readJson(req, res, err => { + if (err) return res.status(400).json({ error: 'Settings were not valid JSON' }); + next(); + }); + + router.put('/settings', jsonBody, async (req, res) => { + if (!req.is('application/json')) return res.status(415).json({ error: 'Send settings as JSON' }); + const input = { ...req.body }; + for (const field of ['sites', 'groups', 'technicians']) { + if (Array.isArray(input[field])) input[field] = nameCache.canonical(field, input[field]); + } + try { + await userSettings.save(req.session.user, input); + } catch (err) { + if (err instanceof SettingsError) return res.status(400).json({ error: err.message }); + console.error('Could not save settings:', err.message); + return res.status(500).json({ error: 'The server could not save your settings' }); + } + res.json(settingsReply(req.session.user)); + }); + + router.delete('/settings', async (req, res) => { + try { + await userSettings.reset(req.session.user); + } catch (err) { + console.error('Could not reset settings:', err.message); + return res.status(500).json({ error: 'The server could not reset your settings' }); + } + res.json(settingsReply(req.session.user)); + }); + + // suggestions for the settings panel; typing any name still works + router.get('/names', signedOutOnSdp401(async (req, res) => { + res.json(await nameCache.list(req.session.accessToken)); + })); + router.get('/closed', signedOutOnSdp401(async (req, res) => { + const prefs = userSettings.forUser(req.session.user); // the page asks for the full feed on first load, then a short overlapping window each check - const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), timing.feedHours * 60); + const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), prefs.feedHours * 60); const state = { lastCheck: null, lastError: null }; const tickets = new Map(); @@ -94,9 +137,9 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) { accessToken: req.session.accessToken, state, tickets, - technicians: settings.technicians, + technicians: prefs.technicians, statusSet: settings.statusSet, - scope: settings.scope, + scope: { sites: prefs.sites, groups: prefs.groups }, windowMinutes: minutes }); @@ -108,28 +151,30 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) { })); router.get('/workload', signedOutOnSdp401(async (req, res) => { + const prefs = userSettings.forUser(req.session.user); + const scope = { sites: prefs.sites, groups: prefs.groups }; const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] }; const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] }; await fetchWorkload({ accessToken: req.session.accessToken, workload, - technicians: settings.technicians, + technicians: prefs.technicians, closedStatuses: settings.closedStatuses, - scope: settings.scope + scope }); await fetchUnassigned({ accessToken: req.session.accessToken, unassigned, excludedStatuses: settings.unassignedExcludedStatuses, - scope: settings.scope + scope }); res.json({ workload, unassigned }); })); router.get('/weather', async (req, res) => { - res.json(await weatherService.getWeather()); + res.json(await weatherService.getWeather(userSettings.forUser(req.session.user).weatherZip)); }); return router; diff --git a/src/server.js b/src/server.js index ad17389..ee62c5f 100644 --- a/src/server.js +++ b/src/server.js @@ -9,6 +9,9 @@ const { createZohoAuth } = require('./auth'); const { createSessions } = require('./session'); const { createAuthRoutes, createApiRoutes } = require('./routes'); const { createWeatherService } = require('./weather'); +const { createUserSettings } = require('./settings'); +const { createNameCache } = require('./names'); +const places = require('./places'); const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env; const PORT = process.env.PORT || 3000; // default 3000 @@ -31,25 +34,19 @@ const statuses = toList(STATUS_NAMES); // statuses that count as done; everything else shows up in the workload table const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled']; -// the region, applied to every query for every user. Kept server side for now; per-browser -// settings (see docs/ROADMAP.md) would override these within limits the server checks. +// the status rules every user shares; these aren't in the settings panel const settings = { - technicians, statusSet: new Set(statuses.map(s => s.toLowerCase())), closedStatuses, // statuses the unassigned counter ignores, to match the ManageEngine view unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined ? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES) - : closedStatuses, - // restricts every query to these sites/groups; empty means no restriction - scope: { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) } + : closedStatuses }; const timing = { windowMinutes: 10, // how far back each closed check looks, in minutes checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes - workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table, in minutes - feedHours: 12, // how long a closed ticket stays in the feed sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again }; @@ -67,8 +64,40 @@ const sessions = createSessions({ zoho }); -// weather widget is optional; it stays hidden unless both values are set -const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE }); +// weather widget is optional; it stays hidden without an API key +const weatherService = createWeatherService({ apiKey: OWM_API_KEY }); + +// the .env zip is the default location; make sure it's on the list people pick from +if (OWM_ZIP_CODE && !places.some(p => p.zip === OWM_ZIP_CODE)) { + places.unshift({ zip: OWM_ZIP_CODE, label: `Default (${OWM_ZIP_CODE})` }); +} + +// what each user starts with; the settings panel overrides these per user +const defaults = { + sites: toList(SITE_NAMES), // empty means every site + groups: toList(GROUP_NAMES), // empty means every group + technicians, // empty means everyone, plus an Unassigned row + workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table + feedHours: 12, // how long a closed ticket stays in the feed + weatherZip: OWM_ZIP_CODE || null, + hiddenStatuses: [], // status columns left out of the table + hiddenPanels: [] // see PANELS in settings.js +}; + +// settings and name suggestions are the only things the server keeps; mount a volume here in docker +const dataDir = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data')); +const userSettings = createUserSettings({ dataDir, defaults, places }); +const nameCache = createNameCache({ + dataDir, + seed: () => { + const saved = userSettings.allNames(); + return { + sites: [...defaults.sites, ...saved.sites], + groups: [...defaults.groups, ...saved.groups], + technicians: [...defaults.technicians, ...saved.technicians] + }; + } +}); const app = express(); app.disable('x-powered-by'); @@ -90,9 +119,9 @@ app.get('/healthz', (req, res) => res.send('ok')); app.use(express.static(path.join(__dirname, 'public'))); app.use('/auth', createAuthRoutes({ zoho, sessions })); -app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing })); +app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places })); //starts listening on port specified app.listen(PORT, () => { - console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}`); + console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}, data in ${dataDir}`); }); diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 0000000..d9ef751 --- /dev/null +++ b/src/settings.js @@ -0,0 +1,143 @@ +const path = require('path'); +const { createJsonFile } = require('./store'); + +// What the server accepts from the settings panel. These protect SDP and the server, not +// access: every query runs with the user's own token, so no setting can show more than SDP +// already lets them see. +const LIMITS = { + sites: 50, + groups: 50, + technicians: 100, + hiddenStatuses: 50, + nameLength: 100, + workloadMinutes: { min: 2, max: 60 }, + feedHours: { min: 1, max: 24 } +}; + +// sections of the page that can be switched off; the closed watcher and its ding keep +// running when the feed is hidden +const PANELS = ['stats', 'weather', 'feed']; + +const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses']; + +class SettingsError extends Error {} + +// trims, collapses spaces and drops case-insensitive repeats; rejects anything that isn't a plain name +function cleanList(value, field) { + if (!Array.isArray(value)) throw new SettingsError(`${field} must be a list`); + const seen = new Set(); + const out = []; + for (const item of value) { + if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`); + const name = item.replace(/\s+/g, ' ').trim(); + if (!name) continue; + if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`); + if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`); + if (seen.has(name.toLowerCase())) continue; + seen.add(name.toLowerCase()); + out.push(name); + } + if (out.length > LIMITS[field]) throw new SettingsError(`At most ${LIMITS[field]} ${field}`); + return out; +} + +function cleanNumber(value, field) { + const n = Number(value); + const { min, max } = LIMITS[field]; + if (!Number.isInteger(n) || n < min || n > max) throw new SettingsError(`${field} must be a whole number from ${min} to ${max}`); + return n; +} + +const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +// Per-user settings, kept on the server and keyed by the email Zoho gave at sign-in, so they +// follow a person between browsers. Only fields that differ from the .env defaults are stored, +// so a user who never touched the region picks up later changes to .env. +function createUserSettings({ dataDir, defaults, places }) { + const file = createJsonFile(path.join(dataDir, 'settings.json'), { users: {} }); + const zips = new Set(places.map(p => p.zip)); + + const keyFor = user => (user?.email ? user.email.toLowerCase() : null); + + function validate(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new SettingsError('Settings must be an object'); + const clean = {}; + for (const field of LIST_FIELDS) { + if (input[field] !== undefined) clean[field] = cleanList(input[field], field); + } + for (const field of ['workloadMinutes', 'feedHours']) { + if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field); + } + if (input.weatherZip !== undefined) { + if (!zips.has(input.weatherZip)) throw new SettingsError('Pick a weather location from the list'); + clean.weatherZip = input.weatherZip; + } + if (input.hiddenPanels !== undefined) { + if (!Array.isArray(input.hiddenPanels) || input.hiddenPanels.some(p => !PANELS.includes(p))) { + throw new SettingsError(`hiddenPanels can only contain ${PANELS.join(', ')}`); + } + clean.hiddenPanels = PANELS.filter(p => input.hiddenPanels.includes(p)); + } + return clean; + } + + function stored(user) { + const key = keyFor(user); + return key ? file.get().users[key] : undefined; + } + + // the defaults with this user's changes on top + function forUser(user) { + const record = stored(user); + const prefs = { ...defaults, ...(record?.settings || {}) }; + // a location dropped from places.js falls back to the default rather than breaking weather + if (!zips.has(prefs.weatherZip)) prefs.weatherZip = defaults.weatherZip; + return prefs; + } + + async function save(user, input) { + const key = keyFor(user); + if (!key) throw new SettingsError('Zoho didn\'t share your email at sign-in, so settings can\'t be saved. Sign out and in again.'); + const clean = validate(input); + const overrides = {}; + for (const [field, value] of Object.entries(clean)) { + if (!sameValue(value, defaults[field])) overrides[field] = value; + } + file.get().users[key] = { settings: overrides, updatedAt: Date.now() }; + await file.save(); + return forUser(user); + } + + async function reset(user) { + const key = keyFor(user); + if (!key || !file.get().users[key]) return forUser(user); + // keep an empty record so the first-run prompt doesn't come back + file.get().users[key] = { settings: {}, updatedAt: Date.now() }; + await file.save(); + return forUser(user); + } + + return { + forUser, + // false until the user has saved once; the page shows the setup prompt until then + hasSaved: user => !!stored(user), + canSave: user => !!keyFor(user), + save, + reset, + // every name anyone has saved, to help the suggestions along + allNames: () => { + const names = { sites: [], groups: [], technicians: [] }; + for (const { settings } of Object.values(file.get().users)) { + for (const field of Object.keys(names)) names[field].push(...(settings[field] || [])); + } + return names; + } + }; +} + +module.exports = { + LIMITS, + PANELS, + SettingsError, + createUserSettings +}; diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..cf4779d --- /dev/null +++ b/src/store.js @@ -0,0 +1,38 @@ +const fs = require('fs'); +const path = require('path'); + +// A small JSON file the server keeps in memory and writes back on change. Enough for a few +// dozen users' settings; one process owns the file, so there's no locking beyond queuing writes. +function createJsonFile(file, initial) { + let value = initial; + try { + value = { ...initial, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; + } catch (err) { + if (err.code !== 'ENOENT') console.error(`Could not read ${file}, starting empty:`, err.message); + } + + let queue = Promise.resolve(); + + // write to a temp file and rename, so a crash mid-write never leaves half a file + function save() { + const json = JSON.stringify(value, null, 2); + const tmp = `${file}.tmp`; + const write = queue.then(async () => { + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(tmp, json); + await fs.promises.rename(tmp, file); + }); + // a failed write shouldn't block the ones after it + queue = write.catch(() => {}); + return write; + } + + return { + get: () => value, + save + }; +} + +module.exports = { + createJsonFile +}; diff --git a/src/tickets.js b/src/tickets.js index 889cec0..4c3a6a4 100644 --- a/src/tickets.js +++ b/src/tickets.js @@ -165,11 +165,6 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st }); } - const cutoff = Date.now() - 12 * 60 * 60 * 1000; - for (const [key, ticket] of tickets) { - if (ticket.closedAt < cutoff) tickets.delete(key); - } - state.lastCheck = Date.now(); state.lastError = null; } catch (err) { @@ -317,8 +312,41 @@ async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scop } } +// Site, group and technician names from recently updated tickets anywhere (no region filter), +// for the settings panel's suggestions. Only names come back, never ticket details. +async function fetchRecentNames({ accessToken, days, pages }) { + const since = Date.now() - days * 24 * 60 * 60 * 1000; + const names = { sites: new Set(), groups: new Set(), technicians: new Set() }; + + // its own loop rather than getAllRequests: there will always be more rows than we want, + // which getAllRequests would read as SDP ignoring the criteria + for (let page = 0; page < pages; page++) { + const data = await getRequests(accessToken, { + sort_field: 'last_updated_time', + sort_order: 'desc', + row_count: PAGE_SIZE, + start_index: page * PAGE_SIZE + 1, + fields_required: ['display_id', 'technician', 'site', 'group'], + search_criteria: { field: 'last_updated_time', condition: 'greater than', value: since.toString() } + }); + for (const t of data.requests || []) { + if (t.site?.name) names.sites.add(t.site.name); + if (t.group?.name) names.groups.add(t.group.name); + if (t.technician?.name) names.technicians.add(t.technician.name); + } + if (!data.list_info?.has_more_rows) break; + } + + return { + sites: [...names.sites], + groups: [...names.groups], + technicians: [...names.technicians] + }; +} + module.exports = { fetchClosedTickets, fetchWorkload, - fetchUnassigned + fetchUnassigned, + fetchRecentNames }; diff --git a/src/weather.js b/src/weather.js index 7d72ac1..b41f4f2 100644 --- a/src/weather.js +++ b/src/weather.js @@ -39,17 +39,14 @@ function dailyForecast(forecast) { })); } -function createWeatherService({ apiKey, zipCode }) { - const enabled = !!(apiKey && zipCode); - // US zip by default; "12345,ca" style values pass through untouched - const zip = zipCode && zipCode.includes(',') ? zipCode : `${zipCode},us`; +// one cache entry per zip; zips come from the fixed list in places.js, so this stays small +function createWeatherService({ apiKey }) { + const enabled = !!apiKey; + const cache = new Map(); - let cache = null; - let fetchedAt = 0; - let pending = null; - let lastError = null; - - async function fetchWeather() { + async function fetchWeather(zipCode) { + // US zip by default; "12345,ca" style values pass through untouched + const zip = zipCode.includes(',') ? zipCode : `${zipCode},us`; const params = { zip, units: 'imperial', appid: apiKey }; const [current, forecast] = await Promise.all([ axios.get(`${API_URL}/weather`, { params }), @@ -75,31 +72,34 @@ function createWeatherService({ apiKey, zipCode }) { }; } - async function getWeather() { - if (!enabled) return { enabled: false }; + async function getWeather(zipCode) { + if (!enabled || !zipCode) return { enabled: false }; - if (Date.now() - fetchedAt > CACHE_MINUTES * 60 * 1000) { + if (!cache.has(zipCode)) cache.set(zipCode, { data: null, fetchedAt: 0, pending: null, lastError: null }); + const entry = cache.get(zipCode); + + if (Date.now() - entry.fetchedAt > CACHE_MINUTES * 60 * 1000) { // share one request between callers that arrive while it's in flight - pending = pending || fetchWeather() + entry.pending = entry.pending || fetchWeather(zipCode) .then(data => { - cache = data; - lastError = null; + entry.data = data; + entry.lastError = null; }) .catch(err => { - lastError = err.response?.data?.message || err.message; - console.error('Weather error: ', lastError); + entry.lastError = err.response?.data?.message || err.message; + console.error(`Weather error (${zipCode}): `, entry.lastError); }) .finally(() => { - fetchedAt = Date.now(); - pending = null; + entry.fetchedAt = Date.now(); + entry.pending = null; }); - await pending; + await entry.pending; } - return { enabled: true, error: lastError, ...cache }; + return { enabled: true, error: entry.lastError, ...entry.data }; } - return { getWeather }; + return { enabled, getWeather }; } module.exports = {