sdp-request-watcher/src/tickets.js
Skylar Grant 80a5e2c965 Add per-user closed statuses for the feed and ding
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>
2026-10-19 11:27:17 -04:00

363 lines
15 KiB
JavaScript

const axios = require('axios');
const API_URL = 'https://ithelp.mainecc.edu/api/v3/requests';
const PAGE_SIZE = 100; // SDP caps row_count at 100 per page
const MAX_PAGES = 20; // safety net so a bad filter can't page forever
function toMillis(completedTime) {
if (!completedTime) return null;
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
const ms = Number(raw);
return Number.isFinite(ms) ? ms : null;
}
// the time field that says when a ticket was done. With preferResolved, a resolved ticket counts
// from its resolve, so the automatic close a few days later doesn't bring it back as a fresh close.
function doneField(ticket, preferResolved) {
const order = preferResolved
? ['resolved_time', 'completed_time', 'last_updated_time']
: ['completed_time', 'resolved_time', 'last_updated_time'];
return order.find(field => toMillis(ticket[field])) || null;
}
function getRelevantTicketTime(ticket, preferResolved = false) {
const field = doneField(ticket, preferResolved);
return field ? toMillis(ticket[field]) : null;
}
// the user's token was rejected; let the route sign them out instead of showing it as a data error
function rethrowIfSignedOut(err) {
if (err.response?.status === 401) throw err;
}
function describeError(err) {
const detail = err.response?.data;
console.error('Error: ', detail || err.message);
return detail?.response_status?.messages?.[0]?.message
|| (typeof detail === 'string' ? detail : null)
|| err.message;
}
// joins search criteria with AND, skipping any that are null
function allOf(...criteria) {
return criteria.flat().filter(Boolean).map((c, i) => (i ? { ...c, logical_operator: 'AND' } : c));
}
// limits a query to the configured sites/groups (the region)
function scopeCriteria(scope) {
return [
scope.sites.length ? { field: 'site.name', condition: 'is', values: scope.sites } : null,
scope.groups.length ? { field: 'group.name', condition: 'is', values: scope.groups } : null
];
}
// same check done locally, in case the API ever returns something outside the region
function inScope(ticket, scope) {
const matches = (list, name) => !list.length
|| (!!name && list.some(item => item.toLowerCase() === name.toLowerCase()));
return matches(scope.sites, ticket.site?.name) && matches(scope.groups, ticket.group?.name);
}
async function getRequests(accessToken, listInfo) {
const response = await axios.get(API_URL, {
params: { input_data: JSON.stringify({ list_info: listInfo }) },
headers: {
Authorization: `Zoho-oauthtoken ${accessToken}`,
Accept: 'application/vnd.manageengine.sdp.v3+json'
}
});
return response.data || {};
}
async function getAllRequests(accessToken, listInfo, label) {
const byId = new Map();
let totalCount = null;
let pages = 0;
let duplicates = 0;
let truncated = true;
for (let page = 0; page < MAX_PAGES; page++) {
const data = await getRequests(accessToken, {
...listInfo,
row_count: PAGE_SIZE,
start_index: page * PAGE_SIZE + 1,
get_total_count: page === 0
});
pages++;
if (page === 0) totalCount = data.list_info?.total_count ?? null;
// dedupe by id so a page that repeats (or shifts as tickets change) can't double count
let added = 0;
for (const ticket of data.requests || []) {
const id = String(ticket.id ?? ticket.display_id);
if (byId.has(id)) duplicates++;
else { byId.set(id, ticket); added++; }
}
if (!data.list_info?.has_more_rows) { truncated = false; break; }
// more matches than we could ever page through almost always means the API ignored our
// search criteria; don't spend 19 more calls downloading the wrong data
if (totalCount > PAGE_SIZE * MAX_PAGES) break;
// a full page of repeats means start_index isn't moving us forward; stop rather than loop
if (!added) break;
}
const requests = [...byId.values()];
if (truncated || duplicates) {
console.warn(`[${label}] pages=${pages} unique=${requests.length} duplicates=${duplicates} total_count=${totalCount} truncated=${truncated}`);
}
return { requests, truncated, totalCount };
}
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, scope, windowMinutes }) {
if (!accessToken) return;
try {
const windowStart = Date.now() - windowMinutes * 60 * 1000;
// paged, because the page asks for the last 12 hours on first load, not just a few minutes
const data = await getAllRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
// never ask for subject: it can name students (FERPA), so it must not leave SDP
fields_required: [
'display_id',
'status',
'completed_time',
'resolved_time',
'last_updated_time',
'technician',
'site',
'group'
],
search_criteria: allOf(
{
field: 'last_updated_time',
condition: 'greater than',
value: windowStart.toString()
},
scopeCriteria(scope)
)
}, 'closed feed');
const found = data.requests;
const preferResolved = statusSet.has('resolved');
for (const ticket of found) {
if (!inScope(ticket, scope)) continue;
const statusName = ticket.status?.name || '';
const statusInternalName = ticket.status?.internal_name || '';
const statusMatches = !statusSet.size
|| statusSet.has(statusName.toLowerCase())
|| statusSet.has(statusInternalName.toLowerCase());
if (!statusMatches) continue;
const closedField = doneField(ticket, preferResolved);
const closedAt = closedField ? toMillis(ticket[closedField]) : null;
if (!closedAt || closedAt <= windowStart) continue;
const technician = ticket.technician ? ticket.technician.name : null;
if (!technician) continue;
if (technicians.length && !technicians.includes(technician)) continue;
tickets.set(String(ticket.display_id), {
id: ticket.display_id,
technician,
status: statusName || null,
closedAt,
closedDisplay: ticket[closedField]?.display_value || null
});
}
state.lastCheck = Date.now();
state.lastError = null;
} catch (err) {
rethrowIfSignedOut(err);
state.lastError = describeError(err);
state.lastCheck = Date.now();
}
}
// Counts every open ticket by technician and status, plus how many each
// technician has closed since midnight.
async function fetchWorkload({ accessToken, workload, technicians, closedStatuses, scope }) {
if (!accessToken) return;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const closedLower = new Set(closedStatuses.map(s => s.toLowerCase()));
const byLowerName = new Map(technicians.map(t => [t.toLowerCase(), t]));
const techCriterion = technicians.length
? { field: 'technician.name', condition: 'is', values: technicians }
: null;
// maps a ticket to the row it's counted under, or null to skip it
function rowFor(ticket) {
if (!inScope(ticket, scope)) return null;
const name = ticket.technician?.name;
if (!technicians.length) return name || 'Unassigned';
return name ? byLowerName.get(name.toLowerCase()) || null : null;
}
try {
const open = await getAllRequests(accessToken, {
sort_field: 'created_time',
sort_order: 'desc',
fields_required: ['display_id', 'status', 'technician', 'site', 'group'],
search_criteria: allOf(
{ field: 'status.name', condition: 'is not', values: closedStatuses },
techCriterion,
scopeCriteria(scope)
)
}, 'open tickets');
const closed = await getAllRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'completed_time', 'resolved_time', 'last_updated_time'],
search_criteria: allOf(
{ field: 'last_updated_time', condition: 'greater than', value: startOfDay.getTime().toString() },
{ field: 'status.name', condition: 'is', values: closedStatuses },
techCriterion,
scopeCriteria(scope)
)
}, 'closed today');
const rows = new Map(technicians.map(t => [t, { name: t, counts: {}, open: 0, closedToday: 0 }]));
const row = name => {
if (!rows.has(name)) rows.set(name, { name, counts: {}, open: 0, closedToday: 0 });
return rows.get(name);
};
const statuses = new Map();
for (const ticket of open.requests) {
const status = ticket.status?.name;
if (!status || closedLower.has(status.toLowerCase())) continue;
const tech = rowFor(ticket);
if (!tech) continue;
const r = row(tech);
r.counts[status] = (r.counts[status] || 0) + 1;
r.open++;
if (!statuses.has(status)) {
const color = /^#[0-9a-f]{3,8}$/i.test(ticket.status.color || '') ? ticket.status.color : null;
statuses.set(status, { name: status, color, total: 0 });
}
statuses.get(status).total++;
}
for (const ticket of closed.requests) {
const closedAt = getRelevantTicketTime(ticket);
if (!closedAt || closedAt < startOfDay.getTime()) continue;
const tech = rowFor(ticket);
if (tech) row(tech).closedToday++;
}
// Open first, then busiest statuses
workload.statuses = [...statuses.values()].sort((a, b) =>
(b.name === 'Open') - (a.name === 'Open') || b.total - a.total || a.name.localeCompare(b.name));
workload.technicians = [...rows.values()];
if (!technicians.length) workload.technicians.sort((a, b) => a.name.localeCompare(b.name));
// which query hit the page cap, and how many tickets SDP says matched, so the page can explain it
const cutOff = (label, result) => result.truncated
&& `${label} (${result.requests.length} of ${result.totalCount ?? '?'})`;
workload.truncated = [cutOff('open tickets', open), cutOff('closed today', closed)]
.filter(Boolean).join(', ') || false;
workload.updatedAt = Date.now();
workload.error = null;
} catch (err) {
rethrowIfSignedOut(err);
workload.error = describeError(err);
workload.updatedAt = Date.now();
}
}
// Mirrors the "Central Region Unassigned" view: in-scope, not excluded, no technician.
async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scope }) {
if (!accessToken) return;
const excludedLower = new Set(excludedStatuses.map(s => s.toLowerCase()));
try {
const result = await getAllRequests(accessToken, {
sort_field: 'created_time',
sort_order: 'desc',
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'created_time'],
search_criteria: allOf(
scopeCriteria(scope),
excludedStatuses.length ? { field: 'status.name', condition: 'is not', values: excludedStatuses } : null,
// "is empty": the API documents "technician is not empty" as condition 'is not' with no value
{ field: 'technician', condition: 'is' }
)
}, 'unassigned');
unassigned.tickets = result.requests
.filter(t => !t.technician && inScope(t, scope) && !excludedLower.has((t.status?.name || '').toLowerCase()))
.map(t => ({
id: t.display_id,
status: t.status?.name || null,
site: t.site?.name || null,
group: t.group?.name || null,
created: t.created_time?.display_value || null
}));
unassigned.count = unassigned.tickets.length;
// how many the API returned that we threw out locally; non-zero means the criteria are off
unassigned.discarded = result.requests.length - unassigned.count;
unassigned.truncated = result.truncated;
unassigned.updatedAt = Date.now();
unassigned.error = null;
} catch (err) {
rethrowIfSignedOut(err);
unassigned.error = describeError(err);
unassigned.updatedAt = Date.now();
}
}
// Site, group and technician names from recently updated tickets anywhere (no region filter),
// for the settings panel's suggestions. Only names come back, never ticket details.
async function fetchRecentNames({ accessToken, days, pages }) {
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const names = { sites: new Set(), groups: new Set(), technicians: new Set() };
// its own loop rather than getAllRequests: there will always be more rows than we want,
// which getAllRequests would read as SDP ignoring the criteria
for (let page = 0; page < pages; page++) {
const data = await getRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
row_count: PAGE_SIZE,
start_index: page * PAGE_SIZE + 1,
fields_required: ['display_id', 'technician', 'site', 'group'],
search_criteria: { field: 'last_updated_time', condition: 'greater than', value: since.toString() }
});
for (const t of data.requests || []) {
if (t.site?.name) names.sites.add(t.site.name);
if (t.group?.name) names.groups.add(t.group.name);
if (t.technician?.name) names.technicians.add(t.technician.name);
}
if (!data.list_info?.has_more_rows) break;
}
return {
sites: [...names.sites],
groups: [...names.groups],
technicians: [...names.technicians]
};
}
// one row, to prove a token can read this portal's requests before handing out cached names
async function checkRequestAccess(accessToken) {
await getRequests(accessToken, { row_count: 1, fields_required: ['display_id'] });
}
module.exports = {
checkRequestAccess,
fetchClosedTickets,
fetchWorkload,
fetchUnassigned,
fetchRecentNames
};