sdp-request-watcher/src/settings.js
Skylar Grant dc684fd7fa
All checks were successful
Build and push image / build (push) Successful in 15s
Sort the technician table by clicking its headers
Replaces the "Sort rows by" setting from the previous commit. Every
header is now a button: a new column starts A to Z for names and highest
first for counts, and a second click reverses it. Rows default to name
A to Z, and each browser remembers its own sort in localStorage, like the
sound toggle. The arrow points toward the larger end (down for A to Z
or lowest first, up for highest first). The tableSort setting is removed
from the server again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:42:31 -04:00

147 lines
6.0 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,
feedStatuses: 20,
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', 'feedStatuses'];
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);
}
// an empty list would make the feed take any status, so a ticket moved to "In Progress" would ding
if (clean.feedStatuses && !clean.feedStatuses.length) throw new SettingsError('Pick at least one status that counts as closed');
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
};