Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e592f9a0d | |||
| e1bbee36d8 | |||
| d8253631f1 |
17
package.json
17
package.json
@ -1,7 +1,22 @@
|
|||||||
{
|
{
|
||||||
|
"name": "sdp-request-watcher",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A local NodeJS app and web server to watch Zoho ManageEngine SDP via API.",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git"
|
||||||
|
},
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "Skylar Grant",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
const { startAuthentication } = require('./auth');
|
const { startAuthentication } = require('./auth');
|
||||||
const { fetchClosedTickets, fetchUnassignedCount } = require('./tickets');
|
const { fetchClosedTickets } = require('./tickets');
|
||||||
|
|
||||||
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes, unassignedStatusSet, unassignedSiteSet, unassignedGroupSet }) {
|
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
|
||||||
const state = {
|
const state = {
|
||||||
auth: 'disconnected',
|
auth: 'disconnected',
|
||||||
verificationUrl: null,
|
verificationUrl: null,
|
||||||
userCode: null,
|
userCode: null,
|
||||||
lastCheck: null,
|
lastCheck: null,
|
||||||
lastError: null,
|
lastError: null
|
||||||
unassignedCount: null,
|
|
||||||
unassignedError: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tickets = new Map();
|
const tickets = new Map();
|
||||||
@ -27,14 +25,6 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
statusSet,
|
statusSet,
|
||||||
windowMinutes
|
windowMinutes
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetchUnassignedCount({
|
|
||||||
accessToken,
|
|
||||||
state,
|
|
||||||
statusSet: unassignedStatusSet,
|
|
||||||
siteSet: unassignedSiteSet,
|
|
||||||
groupSet: unassignedGroupSet
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
|
|||||||
@ -2,13 +2,3 @@ CLIENT_ID=
|
|||||||
CLIENT_SECRET=
|
CLIENT_SECRET=
|
||||||
TECHNICIAN_NAMES=First Last, First Last
|
TECHNICIAN_NAMES=First Last, First Last
|
||||||
STATUS_NAMES=Closed,Resolved
|
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
|
|
||||||
@ -91,31 +91,6 @@ h1 {
|
|||||||
}
|
}
|
||||||
.status[data-auth="error"] .dot { background: var(--alert); }
|
.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 {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.25; }
|
50% { opacity: 0.25; }
|
||||||
@ -327,10 +302,6 @@ body > footer {
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>Closed ticket board</h1>
|
<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">
|
<div class="status" id="status" data-auth="disconnected">
|
||||||
<span class="dot"></span><span id="statusText">Not connected</span>
|
<span class="dot"></span><span id="statusText">Not connected</span>
|
||||||
</div>
|
</div>
|
||||||
@ -404,23 +375,6 @@ function render() {
|
|||||||
$('status').dataset.auth = data.auth;
|
$('status').dataset.auth = data.auth;
|
||||||
$('statusText').textContent = STATUS_TEXT[data.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 = [
|
$('meta').innerHTML = [
|
||||||
'Checks every ' + data.checkMinutes + ' min',
|
'Checks every ' + data.checkMinutes + ' min',
|
||||||
'Window ' + data.windowMinutes + ' min',
|
'Window ' + data.windowMinutes + ' min',
|
||||||
@ -519,7 +473,6 @@ setInterval(load, 5000); // pull fresh state from the server
|
|||||||
setInterval(render, 20000); // re-age the rails between pulls
|
setInterval(render, 20000); // re-age the rails between pulls
|
||||||
</script>
|
</script>
|
||||||
<footer>
|
<footer>
|
||||||
<p>© 2026 <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span>. All rights reserved.</p>
|
|
||||||
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p>
|
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ const express = require('express');
|
|||||||
const { createBoardService } = require('./boardService');
|
const { createBoardService } = require('./boardService');
|
||||||
const { createRoutes } = require('./routes');
|
const { createRoutes } = require('./routes');
|
||||||
|
|
||||||
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, UNASSIGNED_STATUS_NAMES, UNASSIGNED_SITES, UNASSIGNED_GROUP } = process.env;
|
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
|
||||||
const PORT = process.env.PORT || 3000; // default 3000
|
const PORT = process.env.PORT || 3000; // default 3000
|
||||||
|
|
||||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||||
@ -18,15 +18,6 @@ const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t
|
|||||||
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
|
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
|
||||||
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
|
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 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
|
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
|
||||||
|
|
||||||
@ -39,10 +30,7 @@ const boardService = createBoardService({
|
|||||||
technicians,
|
technicians,
|
||||||
statusSet,
|
statusSet,
|
||||||
windowMinutes: WINDOW_MINUTES,
|
windowMinutes: WINDOW_MINUTES,
|
||||||
checkMinutes: CHECK_MINUTES,
|
checkMinutes: CHECK_MINUTES
|
||||||
unassignedStatusSet,
|
|
||||||
unassignedSiteSet,
|
|
||||||
unassignedGroupSet
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|||||||
@ -99,78 +99,6 @@ 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 = {
|
module.exports = {
|
||||||
fetchClosedTickets,
|
fetchClosedTickets
|
||||||
fetchUnassignedCount
|
|
||||||
};
|
};
|
||||||
Loading…
Reference in New Issue
Block a user