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