Bunch of stuff related to getting central region unassigned ticket amount on the site.

This commit is contained in:
Isaac 2026-07-29 16:00:39 -04:00
parent 07866bdcd2
commit e2d53b4fab
5 changed files with 157 additions and 7 deletions

View File

@ -1,13 +1,15 @@
const { startAuthentication } = require('./auth');
const { fetchClosedTickets } = require('./tickets');
const { fetchClosedTickets, fetchUnassignedCount } = require('./tickets');
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes, unassignedStatusSet, unassignedSiteSet, unassignedGroupSet }) {
const state = {
auth: 'disconnected',
verificationUrl: null,
userCode: null,
lastCheck: null,
lastError: null
lastError: null,
unassignedCount: null,
unassignedError: null
};
const tickets = new Map();
@ -25,6 +27,14 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
statusSet,
windowMinutes
});
await fetchUnassignedCount({
accessToken,
state,
statusSet: unassignedStatusSet,
siteSet: unassignedSiteSet,
groupSet: unassignedGroupSet
});
}
function startPolling() {

View File

@ -2,3 +2,13 @@ CLIENT_ID=
CLIENT_SECRET=
TECHNICIAN_NAMES=First Last, First Last
STATUS_NAMES=Closed,Resolved
# Unassigned queue number: counts tickets with no technician assigned,
# matching all of the statuses/sites below and this support group.
# Note the exact spelling "Need Information" (not "Needs Information") -
# that's the literal status name in this SDP instance.
UNASSIGNED_STATUS_NAMES=Open,In Progress,Need Information
UNASSIGNED_SITES=KVCC,CMCC,EMCC,CMCC-Damariscotta,KVCC-Alfond,MCCS
UNASSIGNED_GROUP=Customer Support

View File

@ -91,6 +91,31 @@ h1 {
}
.status[data-auth="error"] .dot { background: var(--alert); }
.unassigned {
display: flex;
align-items: baseline;
gap: 8px;
font-family: var(--mono);
}
.unassigned[hidden] { display: none; }
.unassignedNum {
font-size: 28px;
font-weight: 600;
color: var(--signal);
line-height: 1;
}
.unassignedNum[data-error] { color: var(--alert); }
.unassignedLabel {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
@ -302,6 +327,10 @@ body > footer {
<header>
<h1>Closed ticket board</h1>
<div class="unassigned" id="unassigned" hidden>
<span class="unassignedNum" id="unassignedNum">–</span>
<span class="unassignedLabel">Unassigned</span>
</div>
<div class="status" id="status" data-auth="disconnected">
<span class="dot"></span><span id="statusText">Not connected</span>
</div>
@ -375,6 +404,23 @@ function render() {
$('status').dataset.auth = data.auth;
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
const unassignedEl = $('unassigned');
const unassignedNumEl = $('unassignedNum');
if (data.auth === 'connected' && (data.unassignedCount !== null || data.unassignedError)) {
unassignedEl.hidden = false;
if (data.unassignedError) {
unassignedNumEl.textContent = '—';
unassignedNumEl.dataset.error = 'true';
unassignedEl.title = 'Unassigned check failed: ' + data.unassignedError;
} else {
unassignedNumEl.textContent = data.unassignedCount;
delete unassignedNumEl.dataset.error;
unassignedEl.title = '';
}
} else {
unassignedEl.hidden = true;
}
$('meta').innerHTML = [
'Checks every ' + data.checkMinutes + ' min',
'Window ' + data.windowMinutes + ' min',

View File

@ -6,7 +6,7 @@ const express = require('express');
const { createBoardService } = require('./boardService');
const { createRoutes } = require('./routes');
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, UNASSIGNED_STATUS_NAMES, UNASSIGNED_SITES, UNASSIGNED_GROUP } = process.env;
const PORT = process.env.PORT || 3000; // default 3000
if (!CLIENT_ID || !CLIENT_SECRET) {
@ -18,6 +18,15 @@ const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
const unassignedStatuses = (UNASSIGNED_STATUS_NAMES ? UNASSIGNED_STATUS_NAMES.split(',') : ['Open', 'In Progress', 'Need Information']).map(s => s.trim()).filter(Boolean);
const unassignedStatusSet = new Set(unassignedStatuses.map(s => s.toLowerCase()));
const unassignedSites = (UNASSIGNED_SITES ? UNASSIGNED_SITES.split(',') : ['KVCC', 'CMCC', 'EMCC']).map(s => s.trim()).filter(Boolean);
const unassignedSiteSet = new Set(unassignedSites.map(s => s.toLowerCase()));
const unassignedGroups = (UNASSIGNED_GROUP ? UNASSIGNED_GROUP.split(',') : ['Customer Support']).map(g => g.trim()).filter(Boolean);
const unassignedGroupSet = new Set(unassignedGroups.map(g => g.toLowerCase()));
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
@ -30,7 +39,10 @@ const boardService = createBoardService({
technicians,
statusSet,
windowMinutes: WINDOW_MINUTES,
checkMinutes: CHECK_MINUTES
checkMinutes: CHECK_MINUTES,
unassignedStatusSet,
unassignedSiteSet,
unassignedGroupSet
});
app.use(express.static(path.join(__dirname, 'public')));

View File

@ -99,6 +99,78 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
}
}
module.exports = {
fetchClosedTickets
async function fetchUnassignedCount({ accessToken, state, statusSet, siteSet, groupSet }) {
if (!accessToken) return;
try {
let count = 0;
let startIndex = 1;
const rowCount = 100;
const maxPages = 50; // safety cap: 5000 unassigned tickets
// The SDP instance ignores search_criteria arrays with more than one
// condition, so only "technician is unassigned" is filtered server-side;
// status/site/group are matched client-side per ticket below.
for (let page = 0; page < maxPages; page++) {
const listInfo = {
list_info: {
row_count: rowCount,
start_index: startIndex,
search_criteria: {
field: 'technician',
condition: 'is',
value: null
},
fields_required: ['status', 'technician', 'group', 'requester']
}
};
const response = await axios.get('https://ithelp.mainecc.edu/api/v3/requests', {
params: { input_data: JSON.stringify(listInfo) },
headers: {
Authorization: `Zoho-oauthtoken ${accessToken}`,
Accept: 'application/vnd.manageengine.sdp.v3+json'
}
});
const found = response.data?.requests || [];
for (const ticket of found) {
if (ticket.technician) continue;
const statusName = (ticket.status?.name || '').toLowerCase();
const statusInternalName = (ticket.status?.internal_name || '').toLowerCase();
const statusMatches = !statusSet.size
|| statusSet.has(statusName)
|| statusSet.has(statusInternalName);
if (!statusMatches) continue;
const groupName = (ticket.group?.name || '').toLowerCase();
if (groupSet.size && !groupSet.has(groupName)) continue;
const siteName = (ticket.requester?.site?.name || '').toLowerCase();
if (siteSet.size && !siteSet.has(siteName)) continue;
count++;
}
if (!response.data?.list_info?.has_more_rows || found.length === 0) break;
startIndex += rowCount;
}
state.unassignedCount = count;
state.unassignedError = null;
} catch (err) {
const detail = err.response?.data;
state.unassignedError = detail?.response_status?.messages?.[0]?.message
|| (typeof detail === 'string' ? detail : null)
|| err.message;
state.unassignedCount = null;
console.error('Error fetching unassigned count: ', detail || err.message);
}
}
module.exports = {
fetchClosedTickets,
fetchUnassignedCount
};