SDP closes a resolved ticket on its own after 2 or 3 days, so watching only Closed gave the ding days late. Each user can now pick which statuses count as closed for the feed (feedStatuses, defaulting to STATUS_NAMES). With Resolved picked, tickets are timed by resolved_time so the automatic close later doesn't ding again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
8.2 KiB
JavaScript
205 lines
8.2 KiB
JavaScript
const express = require('express');
|
|
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
|
|
const { LIMITS, PANELS, SettingsError } = require('./settings');
|
|
|
|
// Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account.
|
|
function createAuthRoutes({ zoho, sessions }) {
|
|
const router = express.Router();
|
|
|
|
router.get('/login', (req, res) => {
|
|
res.redirect(zoho.authorizeUrl(sessions.startState(res)));
|
|
});
|
|
|
|
router.get('/callback', async (req, res) => {
|
|
if (!sessions.checkState(req, res, req.query.state)) {
|
|
return res.redirect('/?signin=expired');
|
|
}
|
|
if (req.query.error || !req.query.code) {
|
|
console.warn('Sign-in refused by Zoho:', req.query.error || 'no code');
|
|
return res.redirect('/?signin=denied');
|
|
}
|
|
|
|
try {
|
|
const tokens = await zoho.exchangeCode(String(req.query.code));
|
|
const user = await zoho.getUser(tokens.accessToken);
|
|
if (!sessions.isAllowed(user)) {
|
|
console.warn(`Sign-in refused, not an allowed domain: ${user.email || 'no email'} from ${req.ip}`);
|
|
// they never get a session, so drop the token Zoho just issued
|
|
await zoho.revokeToken(tokens.refreshToken).catch(err => {
|
|
console.warn('Could not revoke Zoho token:', err.message);
|
|
});
|
|
return res.redirect('/?signin=notallowed');
|
|
}
|
|
sessions.start(res, tokens, user);
|
|
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
|
|
res.redirect('/');
|
|
} catch (err) {
|
|
console.error('Sign-in failed:', err.response?.data || err.message);
|
|
res.redirect('/?signin=failed');
|
|
}
|
|
});
|
|
|
|
router.post('/logout', async (req, res) => {
|
|
const session = sessions.read(req);
|
|
sessions.clear(res);
|
|
if (session) {
|
|
console.log(`Signed out: ${session.user?.email || session.user?.name || 'unknown user'}`);
|
|
await zoho.revokeToken(session.refreshToken).catch(err => {
|
|
console.warn('Could not revoke Zoho token:', err.message);
|
|
});
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
// Data: each call runs against SDP with the caller's own token and their saved settings.
|
|
// Nothing is cached per user, so the browser decides how often to ask (see checkMinutes and
|
|
// the user's workloadMinutes).
|
|
function createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }) {
|
|
const router = express.Router();
|
|
|
|
router.use((req, res, next) => {
|
|
res.set('Cache-Control', 'no-store');
|
|
next();
|
|
});
|
|
router.use(sessions.requireSession);
|
|
|
|
// turns an SDP 401 thrown by tickets.js into a sign-out
|
|
const signedOutOnSdp401 = handler => async (req, res) => {
|
|
try {
|
|
await handler(req, res);
|
|
} catch (err) {
|
|
if (err.response?.status !== 401) throw err;
|
|
sessions.clear(res);
|
|
res.status(401).json({ error: 'ServiceDesk Plus rejected your sign-in' });
|
|
}
|
|
};
|
|
|
|
// everything the settings panel needs, sent with the session and after each save
|
|
const settingsReply = user => ({
|
|
settings: userSettings.forUser(user),
|
|
settingsSaved: userSettings.hasSaved(user),
|
|
canSaveSettings: userSettings.canSave(user)
|
|
});
|
|
|
|
router.get('/session', (req, res) => {
|
|
res.json({
|
|
user: req.session.user,
|
|
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
|
|
...settingsReply(req.session.user),
|
|
settingsLimits: { ...LIMITS, panels: PANELS },
|
|
// the help desk's done statuses, offered as choices for feedStatuses
|
|
closedStatuses: settings.closedStatuses,
|
|
places: weatherService.enabled ? places : [],
|
|
checkMinutes: timing.checkMinutes,
|
|
windowMinutes: timing.windowMinutes
|
|
});
|
|
});
|
|
|
|
// only JSON bodies; a cross-site form can't send one without a CORS preflight we never answer
|
|
const readJson = express.json({ limit: '32kb' });
|
|
const jsonBody = (req, res, next) => readJson(req, res, err => {
|
|
if (err) return res.status(400).json({ error: 'Settings were not valid JSON' });
|
|
next();
|
|
});
|
|
|
|
router.put('/settings', jsonBody, async (req, res) => {
|
|
if (!req.is('application/json')) return res.status(415).json({ error: 'Send settings as JSON' });
|
|
const input = { ...req.body };
|
|
for (const field of ['sites', 'groups', 'technicians']) {
|
|
if (Array.isArray(input[field])) input[field] = nameCache.canonical(field, input[field]);
|
|
}
|
|
try {
|
|
await userSettings.save(req.session.user, input);
|
|
} catch (err) {
|
|
if (err instanceof SettingsError) return res.status(400).json({ error: err.message });
|
|
console.error('Could not save settings:', err.message);
|
|
return res.status(500).json({ error: 'The server could not save your settings' });
|
|
}
|
|
res.json(settingsReply(req.session.user));
|
|
});
|
|
|
|
router.delete('/settings', async (req, res) => {
|
|
try {
|
|
await userSettings.reset(req.session.user);
|
|
} catch (err) {
|
|
console.error('Could not reset settings:', err.message);
|
|
return res.status(500).json({ error: 'The server could not reset your settings' });
|
|
}
|
|
res.json(settingsReply(req.session.user));
|
|
});
|
|
|
|
// suggestions for the settings panel; typing any name still works
|
|
router.get('/names', signedOutOnSdp401(async (req, res) => {
|
|
let names;
|
|
try {
|
|
names = await nameCache.list(req.session.accessToken);
|
|
} catch (err) {
|
|
if (err.response?.status === 401) throw err;
|
|
console.warn(`Name suggestions refused for ${req.session.user?.email}:`, err.response?.status || err.message);
|
|
return res.status(403).json({ error: 'ServiceDesk Plus did not allow this request' });
|
|
}
|
|
res.json(names);
|
|
}));
|
|
|
|
router.get('/closed', signedOutOnSdp401(async (req, res) => {
|
|
const prefs = userSettings.forUser(req.session.user);
|
|
// the page asks for the full feed on first load, then a short overlapping window each check
|
|
const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), prefs.feedHours * 60);
|
|
const state = { lastCheck: null, lastError: null };
|
|
const tickets = new Map();
|
|
|
|
await fetchClosedTickets({
|
|
accessToken: req.session.accessToken,
|
|
state,
|
|
tickets,
|
|
technicians: prefs.technicians,
|
|
statusSet: new Set(prefs.feedStatuses.map(s => s.toLowerCase())),
|
|
scope: { sites: prefs.sites, groups: prefs.groups },
|
|
windowMinutes: minutes
|
|
});
|
|
|
|
res.json({
|
|
checkedAt: state.lastCheck,
|
|
error: state.lastError,
|
|
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
|
|
});
|
|
}));
|
|
|
|
router.get('/workload', signedOutOnSdp401(async (req, res) => {
|
|
const prefs = userSettings.forUser(req.session.user);
|
|
const scope = { sites: prefs.sites, groups: prefs.groups };
|
|
const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] };
|
|
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
|
|
|
|
await fetchWorkload({
|
|
accessToken: req.session.accessToken,
|
|
workload,
|
|
technicians: prefs.technicians,
|
|
closedStatuses: settings.closedStatuses,
|
|
scope
|
|
});
|
|
await fetchUnassigned({
|
|
accessToken: req.session.accessToken,
|
|
unassigned,
|
|
excludedStatuses: settings.unassignedExcludedStatuses,
|
|
scope
|
|
});
|
|
|
|
res.json({ workload, unassigned });
|
|
}));
|
|
|
|
router.get('/weather', async (req, res) => {
|
|
res.json(await weatherService.getWeather(userSettings.forUser(req.session.user).weatherZip));
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
module.exports = {
|
|
createAuthRoutes,
|
|
createApiRoutes
|
|
};
|