All checks were successful
Build and push image / build (push) Successful in 13s
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>
67 lines
2.8 KiB
JavaScript
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
|
|
};
|