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 <noreply@anthropic.com>
This commit is contained in:
parent
1a9de77dc6
commit
9479b122fa
63
src/names.js
Normal file
63
src/names.js
Normal file
@ -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
|
||||||
|
};
|
||||||
16
src/places.js
Normal file
16
src/places.js
Normal file
@ -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)' }
|
||||||
|
];
|
||||||
@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
|
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.
|
// Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account.
|
||||||
function createAuthRoutes({ zoho, sessions }) {
|
function createAuthRoutes({ zoho, sessions }) {
|
||||||
@ -45,9 +46,10 @@ function createAuthRoutes({ zoho, sessions }) {
|
|||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data: each call runs against SDP with the caller's own token. Nothing is cached per user,
|
// Data: each call runs against SDP with the caller's own token and their saved settings.
|
||||||
// so the browser decides how often to ask (see checkMinutes and workloadMinutes).
|
// Nothing is cached per user, so the browser decides how often to ask (see checkMinutes and
|
||||||
function createApiRoutes({ sessions, weatherService, settings, timing }) {
|
// the user's workloadMinutes).
|
||||||
|
function createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use((req, res, next) => {
|
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) => {
|
router.get('/session', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
|
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
|
||||||
// the region filters every query uses; a future settings panel would start from these
|
...settingsReply(req.session.user),
|
||||||
settings: {
|
settingsLimits: { ...LIMITS, panels: PANELS },
|
||||||
sites: settings.scope.sites,
|
places: weatherService.enabled ? places : [],
|
||||||
groups: settings.scope.groups,
|
|
||||||
technicians: settings.technicians
|
|
||||||
},
|
|
||||||
checkMinutes: timing.checkMinutes,
|
checkMinutes: timing.checkMinutes,
|
||||||
windowMinutes: timing.windowMinutes,
|
windowMinutes: timing.windowMinutes
|
||||||
workloadMinutes: timing.workloadMinutes,
|
|
||||||
feedHours: timing.feedHours
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
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
|
// 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 state = { lastCheck: null, lastError: null };
|
||||||
const tickets = new Map();
|
const tickets = new Map();
|
||||||
|
|
||||||
@ -94,9 +137,9 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) {
|
|||||||
accessToken: req.session.accessToken,
|
accessToken: req.session.accessToken,
|
||||||
state,
|
state,
|
||||||
tickets,
|
tickets,
|
||||||
technicians: settings.technicians,
|
technicians: prefs.technicians,
|
||||||
statusSet: settings.statusSet,
|
statusSet: settings.statusSet,
|
||||||
scope: settings.scope,
|
scope: { sites: prefs.sites, groups: prefs.groups },
|
||||||
windowMinutes: minutes
|
windowMinutes: minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -108,28 +151,30 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
router.get('/workload', signedOutOnSdp401(async (req, res) => {
|
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 workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] };
|
||||||
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
|
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
|
||||||
|
|
||||||
await fetchWorkload({
|
await fetchWorkload({
|
||||||
accessToken: req.session.accessToken,
|
accessToken: req.session.accessToken,
|
||||||
workload,
|
workload,
|
||||||
technicians: settings.technicians,
|
technicians: prefs.technicians,
|
||||||
closedStatuses: settings.closedStatuses,
|
closedStatuses: settings.closedStatuses,
|
||||||
scope: settings.scope
|
scope
|
||||||
});
|
});
|
||||||
await fetchUnassigned({
|
await fetchUnassigned({
|
||||||
accessToken: req.session.accessToken,
|
accessToken: req.session.accessToken,
|
||||||
unassigned,
|
unassigned,
|
||||||
excludedStatuses: settings.unassignedExcludedStatuses,
|
excludedStatuses: settings.unassignedExcludedStatuses,
|
||||||
scope: settings.scope
|
scope
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ workload, unassigned });
|
res.json({ workload, unassigned });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.get('/weather', async (req, res) => {
|
router.get('/weather', async (req, res) => {
|
||||||
res.json(await weatherService.getWeather());
|
res.json(await weatherService.getWeather(userSettings.forUser(req.session.user).weatherZip));
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@ -9,6 +9,9 @@ const { createZohoAuth } = require('./auth');
|
|||||||
const { createSessions } = require('./session');
|
const { createSessions } = require('./session');
|
||||||
const { createAuthRoutes, createApiRoutes } = require('./routes');
|
const { createAuthRoutes, createApiRoutes } = require('./routes');
|
||||||
const { createWeatherService } = require('./weather');
|
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 { 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
|
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
|
// statuses that count as done; everything else shows up in the workload table
|
||||||
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
||||||
|
|
||||||
// the region, applied to every query for every user. Kept server side for now; per-browser
|
// the status rules every user shares; these aren't in the settings panel
|
||||||
// settings (see docs/ROADMAP.md) would override these within limits the server checks.
|
|
||||||
const settings = {
|
const settings = {
|
||||||
technicians,
|
|
||||||
statusSet: new Set(statuses.map(s => s.toLowerCase())),
|
statusSet: new Set(statuses.map(s => s.toLowerCase())),
|
||||||
closedStatuses,
|
closedStatuses,
|
||||||
// statuses the unassigned counter ignores, to match the ManageEngine view
|
// statuses the unassigned counter ignores, to match the ManageEngine view
|
||||||
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
||||||
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
|
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
|
||||||
: closedStatuses,
|
: closedStatuses
|
||||||
// restricts every query to these sites/groups; empty means no restriction
|
|
||||||
scope: { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timing = {
|
const timing = {
|
||||||
windowMinutes: 10, // how far back each closed check looks, in minutes
|
windowMinutes: 10, // how far back each closed check looks, in minutes
|
||||||
checkMinutes: 1, // how often the page checks for newly closed tickets, 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
|
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
|
zoho
|
||||||
});
|
});
|
||||||
|
|
||||||
// weather widget is optional; it stays hidden unless both values are set
|
// weather widget is optional; it stays hidden without an API key
|
||||||
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE });
|
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();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
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(express.static(path.join(__dirname, 'public')));
|
||||||
app.use('/auth', createAuthRoutes({ zoho, sessions }));
|
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
|
//starts listening on port specified
|
||||||
app.listen(PORT, () => {
|
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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
143
src/settings.js
Normal file
143
src/settings.js
Normal file
@ -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
|
||||||
|
};
|
||||||
38
src/store.js
Normal file
38
src/store.js
Normal file
@ -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
|
||||||
|
};
|
||||||
@ -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.lastCheck = Date.now();
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
} catch (err) {
|
} 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 = {
|
module.exports = {
|
||||||
fetchClosedTickets,
|
fetchClosedTickets,
|
||||||
fetchWorkload,
|
fetchWorkload,
|
||||||
fetchUnassigned
|
fetchUnassigned,
|
||||||
|
fetchRecentNames
|
||||||
};
|
};
|
||||||
|
|||||||
@ -39,17 +39,14 @@ function dailyForecast(forecast) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWeatherService({ apiKey, zipCode }) {
|
// one cache entry per zip; zips come from the fixed list in places.js, so this stays small
|
||||||
const enabled = !!(apiKey && zipCode);
|
function createWeatherService({ apiKey }) {
|
||||||
|
const enabled = !!apiKey;
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
async function fetchWeather(zipCode) {
|
||||||
// US zip by default; "12345,ca" style values pass through untouched
|
// US zip by default; "12345,ca" style values pass through untouched
|
||||||
const zip = zipCode && zipCode.includes(',') ? zipCode : `${zipCode},us`;
|
const zip = zipCode.includes(',') ? zipCode : `${zipCode},us`;
|
||||||
|
|
||||||
let cache = null;
|
|
||||||
let fetchedAt = 0;
|
|
||||||
let pending = null;
|
|
||||||
let lastError = null;
|
|
||||||
|
|
||||||
async function fetchWeather() {
|
|
||||||
const params = { zip, units: 'imperial', appid: apiKey };
|
const params = { zip, units: 'imperial', appid: apiKey };
|
||||||
const [current, forecast] = await Promise.all([
|
const [current, forecast] = await Promise.all([
|
||||||
axios.get(`${API_URL}/weather`, { params }),
|
axios.get(`${API_URL}/weather`, { params }),
|
||||||
@ -75,31 +72,34 @@ function createWeatherService({ apiKey, zipCode }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWeather() {
|
async function getWeather(zipCode) {
|
||||||
if (!enabled) return { enabled: false };
|
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
|
// share one request between callers that arrive while it's in flight
|
||||||
pending = pending || fetchWeather()
|
entry.pending = entry.pending || fetchWeather(zipCode)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
cache = data;
|
entry.data = data;
|
||||||
lastError = null;
|
entry.lastError = null;
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
lastError = err.response?.data?.message || err.message;
|
entry.lastError = err.response?.data?.message || err.message;
|
||||||
console.error('Weather error: ', lastError);
|
console.error(`Weather error (${zipCode}): `, entry.lastError);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
fetchedAt = Date.now();
|
entry.fetchedAt = Date.now();
|
||||||
pending = null;
|
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 = {
|
module.exports = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user