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>
363 lines
15 KiB
JavaScript
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
|
|
};
|