sdp-request-watcher/src/settings.js
Skylar Grant 9479b122fa 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>
2026-10-15 21:04:22 -04:00

144 lines
5.7 KiB
JavaScript

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