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>
144 lines
5.7 KiB
JavaScript
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
|
|
};
|