diff --git a/src/boardService.js b/src/boardService.js index bdc6aa6..4b17e3d 100644 --- a/src/boardService.js +++ b/src/boardService.js @@ -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() { diff --git a/src/env.example b/src/env.example index b1fd72e..40d4663 100644 --- a/src/env.example +++ b/src/env.example @@ -1,4 +1,14 @@ CLIENT_ID= CLIENT_SECRET= TECHNICIAN_NAMES=First Last, First Last -STATUS_NAMES=Closed,Resolved \ No newline at end of file +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 \ No newline at end of file diff --git a/src/public/index.html b/src/public/index.html index df8b266..ade3a4c 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -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 {

Closed ticket board

+
Not connected
@@ -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', diff --git a/src/server.js b/src/server.js index 9e7c620..22b8ee3 100644 --- a/src/server.js +++ b/src/server.js @@ -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'))); diff --git a/src/tickets.js b/src/tickets.js index 0b85a25..cdba1f5 100644 --- a/src/tickets.js +++ b/src/tickets.js @@ -99,6 +99,78 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st } } +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 + fetchClosedTickets, + fetchUnassignedCount }; \ No newline at end of file