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 };