sdp-request-watcher/src/names.js
Skylar Grant 2f137afd5f
All checks were successful
Build and push image / build (push) Successful in 13s
Limit sign-in to allowed email domains
Any Zoho account can approve the client, and an outside account got a
session that returned the .env site, group and technician names.
ALLOWED_EMAIL_DOMAINS is now required: the callback refuses other
domains and revokes their token, and every API call re-checks it so
older cookies stop working. /api/names also makes the caller's token
read from SDP before returning the shared names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:29:25 -04:00

67 lines
2.8 KiB
JavaScript

const path = require('path');
const { createJsonFile } = require('./store');
const { fetchRecentNames, checkRequestAccess } = 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 cache is only handed out once the caller's own token has read from SDP, so the names
// never reach someone SDP turns away. That token also refreshes the cache when it's due; a
// failed refresh still returns the cached names, since the caller has already proved access.
async function list(accessToken) {
await checkRequestAccess(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
};