sdp-request-watcher/src/server.js
Skylar Grant 872a7f17c3 Add a per-user sort order for the technician table
Settings has a "Sort rows by" choice: the technician list order (the
default, as before), technician name, Total open, Closed today or any
status column. Counts sort busiest first with ties by name, and the
sorted column's header gets an arrow. The browser does the sorting; the
server only validates and saves tableSort.

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

140 lines
6.0 KiB
JavaScript

// sdp-request-watcher: help desk dashboard for ManageEngine SDP Cloud
// Skylar Grant, with Claude (Anthropic). Page design by Isaac Godin.
require('dotenv').config({ quiet: true });
const path = require('path');
const express = require('express');
const { createZohoAuth } = require('./auth');
const { createSessions } = require('./session');
const { createAuthRoutes, createApiRoutes } = require('./routes');
const { createWeatherService } = require('./weather');
const { createUserSettings } = require('./settings');
const { createNameCache } = require('./names');
const places = require('./places');
const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
const PORT = process.env.PORT || 3000; // default 3000
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
// any Zoho account can approve our client, so sign-in alone doesn't mean someone is staff
const allowedDomains = toList(process.env.ALLOWED_EMAIL_DOMAINS).map(d => d.replace(/^@/, '').toLowerCase());
const problems = [
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null,
!allowedDomains.length ? 'ALLOWED_EMAIL_DOMAINS is required, e.g. mainecc.edu' : null
].filter(Boolean);
if (problems.length) {
problems.forEach(p => console.error(p));
process.exit(1);
}
const technicians = toList(TECHNICIAN_NAMES);
const statuses = toList(STATUS_NAMES);
// statuses that count as done; everything else shows up in the workload table
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
// the status rules every user shares; these aren't in the settings panel (the feed's statuses are, see feedStatuses)
const settings = {
closedStatuses,
// statuses the unassigned counter ignores, to match the ManageEngine view
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
: closedStatuses
};
const timing = {
windowMinutes: 10, // how far back each closed check looks, in minutes
checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes
sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again
};
const zoho = createZohoAuth({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
redirectUri: `${PUBLIC_URL}/auth/callback`
});
const sessions = createSessions({
secret: SESSION_SECRET,
// the proxy terminates https, so go by the public address rather than the request
secure: PUBLIC_URL.startsWith('https://'),
// exact domain match on the email Zoho reports; no email means no entry
isAllowed: user => {
const email = (user?.email || '').toLowerCase();
return allowedDomains.some(domain => email.endsWith('@' + domain));
},
sessionDays: timing.sessionDays,
zoho
});
// weather widget is optional; it stays hidden without an API key
const weatherService = createWeatherService({ apiKey: OWM_API_KEY });
// the .env zip is the default location; make sure it's on the list people pick from
if (OWM_ZIP_CODE && !places.some(p => p.zip === OWM_ZIP_CODE)) {
places.unshift({ zip: OWM_ZIP_CODE, label: `Default (${OWM_ZIP_CODE})` });
}
// what each user starts with; the settings panel overrides these per user
const defaults = {
sites: toList(SITE_NAMES), // empty means every site
groups: toList(GROUP_NAMES), // empty means every group
technicians, // empty means everyone, plus an Unassigned row
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table
feedHours: 12, // how long a closed ticket stays in the feed
weatherZip: OWM_ZIP_CODE || null,
hiddenStatuses: [], // status columns left out of the table
tableSort: 'list', // see TABLE_SORTS in settings.js
// statuses that put a ticket in the closed feed and ding; some people want Resolved,
// which comes days before SDP closes a ticket on its own
feedStatuses: closedStatuses,
hiddenPanels: [] // see PANELS in settings.js
};
// settings and name suggestions are the only things the server keeps; mount a volume here in docker
const dataDir = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
const userSettings = createUserSettings({ dataDir, defaults, places });
const nameCache = createNameCache({
dataDir,
seed: () => {
const saved = userSettings.allNames();
return {
sites: [...defaults.sites, ...saved.sites],
groups: [...defaults.groups, ...saved.groups],
technicians: [...defaults.technicians, ...saved.technicians]
};
}
});
const app = express();
app.disable('x-powered-by');
// behind nginx proxy manager: believe X-Forwarded-For from private addresses (the docker network)
// so logs show the real client ip
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal');
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin'
});
next();
});
// for the docker healthcheck; says nothing about sign-in or data
app.get('/healthz', (req, res) => res.send('ok'));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/auth', createAuthRoutes({ zoho, sessions }));
app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }));
//starts listening on port specified
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}, data in ${dataDir}`);
});