From d3f338718308e9ab072fdb494ed0bbe6d0faa809 Mon Sep 17 00:00:00 2001 From: Isaac Date: Mon, 27 Jul 2026 13:36:08 -0400 Subject: [PATCH] Separated server.js to make a modular setup should be a bit nicer now. Also fixed the weird bug where when clicking connect it would open a second tab instead of just opening zoho. --- src/auth.js | 80 +++++++++++++++++ src/public/index.html | 2 +- src/server.js | 194 ++++++++---------------------------------- src/tickets.js | 104 ++++++++++++++++++++++ 4 files changed, 219 insertions(+), 161 deletions(-) create mode 100644 src/auth.js create mode 100644 src/tickets.js diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..eb611bb --- /dev/null +++ b/src/auth.js @@ -0,0 +1,80 @@ +const axios = require('axios'); + +async function getDeviceCode(clientId) { + const params = new URLSearchParams({ + client_id: clientId, + scope: 'SDPOnDemand.requests.READ', + grant_type: 'device_request', + access_type: 'offline' + }); + + const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params); + return res.data; +} + +function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) { + const pollParams = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code: deviceCode, + grant_type: 'device_token' + }); + + return new Promise((resolve, reject) => { + const deadline = Date.now() + (expiresIn || 300) * 1000; + + const timer = setInterval(async () => { + if (Date.now() > deadline) { + clearInterval(timer); + reject(new Error('Expired try again.')); + return; + } + + try { + const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams); + if (res.data.access_token) { + clearInterval(timer); + resolve(res.data.access_token); + } + } catch (err) { + } + }, interval + 500); + }); +} + +async function startAuthentication({ clientId, clientSecret, state }) { + state.auth = 'pending'; + state.lastError = null; + + const deviceData = await getDeviceCode(clientId); + state.verificationUrl = deviceData.verification_uri_complete; + state.userCode = deviceData.user_code; + + console.log('Open this URL and authorize:', deviceData.verification_uri_complete); + console.log('Code: ', deviceData.user_code); + + try { + const accessToken = await pollForToken( + clientId, + clientSecret, + deviceData.device_code, + deviceData.interval, + deviceData.expires_in + ); + + state.auth = 'connected'; + state.verificationUrl = null; + state.userCode = null; + return { accessToken }; + } catch (err) { + state.auth = 'error'; + state.lastError = err.message; + state.verificationUrl = null; + state.userCode = null; + return null; + } +} + +module.exports = { + startAuthentication +}; \ No newline at end of file diff --git a/src/public/index.html b/src/public/index.html index d8a18dc..df8b266 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -398,7 +398,7 @@ function render() {

Approve the board

Open the authorization page and confirm this code matches the one shown there.

${esc(data.userCode || '...')}
- Open authorization page + Open authorization page `; } diff --git a/src/server.js b/src/server.js index 25d3bde..3ff533e 100644 --- a/src/server.js +++ b/src/server.js @@ -3,10 +3,11 @@ require('dotenv').config({ quiet: true }); const path = require('path'); const express = require('express'); -const axios = require('axios'); +const { startAuthentication } = require('./auth'); +const { fetchClosedTickets } = require('./tickets'); const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env; -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 3000; // change to 3000 after done testing using 3001 as I have a second server running to make sure it functions correctly if (!CLIENT_ID || !CLIENT_SECRET) { console.error('Client id and secret missing from .env'); @@ -32,176 +33,44 @@ const state = { const tickets = new Map(); -async function getDeviceCode() { - const params = new URLSearchParams({ - client_id: CLIENT_ID, - scope: 'SDPOnDemand.requests.READ', - grant_type: 'device_request', - access_type: 'offline' - }); - - const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params); - return res.data; -} - -function pollForToken(deviceCode, interval, expiresIn) { - const pollParams = new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: deviceCode, - grant_type: 'device_token' - }); - - return new Promise((resolve, reject) => { - const deadline = Date.now() + (expiresIn || 300) * 1000; - - const timer = setInterval(async () => { - if (Date.now() > deadline) { - clearInterval(timer); - reject(new Error('Expired try again.')); - return; - } - - try { - const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams); - if (res.data.access_token) { - clearInterval(timer); - accessToken = res.data.access_token; - console.log('Authorization successful'); - resolve(); - } - } catch (err) { - } - }, interval + 500); - }); -} - -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; -} - -function getRelevantTicketTime(ticket) { - return toMillis(ticket.completed_time) - || toMillis(ticket.resolved_time) - || toMillis(ticket.last_updated_time) - || null; -} - -async function fetchClosedTickets() { - if (!accessToken) return; - +async function connect() { try { - const windowStart = Date.now() - WINDOW_MINUTES * 60 * 1000; - - const listInfo = { - list_info: { - row_count: 200, - sort_field: 'last_updated_time', - sort_order: 'desc', - fields_required: [ - 'display_id', - 'subject', - 'status', - 'completed_time', - 'resolved_time', - 'last_updated_time', - 'technician' - ], - search_criteria: [ - { - field: 'last_updated_time', - condition: 'greater_than', - value: windowStart.toString() - } - ] - } - }; - - 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 authResult = await startAuthentication({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + state }); - const found = response.data?.requests || []; + if (!authResult?.accessToken) return; - for (const ticket of found) { - 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; + accessToken = authResult.accessToken; + console.log('Authorization successful'); - const closedAt = getRelevantTicketTime(ticket); - if (!closedAt || closedAt <= windowStart) continue; + await fetchClosedTickets({ + accessToken, + state, + tickets, + technicians, + statusSet, + windowMinutes: WINDOW_MINUTES + }); - 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, - subject: ticket.subject, - technician, - status: statusName || null, - closedAt, - closedDisplay: ticket.completed_time?.display_value - || ticket.resolved_time?.display_value - || ticket.last_updated_time?.display_value - || null + setInterval(() => { + fetchClosedTickets({ + accessToken, + state, + tickets, + technicians, + statusSet, + windowMinutes: WINDOW_MINUTES }); - } - - const cutoff = Date.now() - 12 * 60 * 60 * 1000; - for (const [key, ticket] of tickets) { - if (ticket.closedAt < cutoff) tickets.delete(key); - } - - state.lastCheck = Date.now(); - state.lastError = null; - } catch (err) { - const detail = err.response?.data; - state.lastError = detail?.response_status?.messages?.[0]?.message - || (typeof detail === 'string' ? detail : null) - || err.message; - state.lastCheck = Date.now(); - console.error('Error: ', detail || err.message); - } -} - -async function connect() { - state.auth = 'pending'; - state.lastError = null; - - const deviceData = await getDeviceCode(); - state.verificationUrl = deviceData.verification_uri_complete; - state.userCode = deviceData.user_code; - - console.log('Open this URL and authorize:', deviceData.verification_uri_complete); - console.log('Code: ', deviceData.user_code); - - try { - await pollForToken(deviceData.device_code, deviceData.interval, deviceData.expires_in); + }, CHECK_MINUTES * 60 * 1000); } catch (err) { state.auth = 'error'; state.lastError = err.message; state.verificationUrl = null; state.userCode = null; - return; } - - state.auth = 'connected'; - state.verificationUrl = null; - state.userCode = null; - - await fetchClosedTickets(); - setInterval(fetchClosedTickets, CHECK_MINUTES * 60 * 1000); } const app = express(); @@ -223,7 +92,12 @@ app.post('/api/connect', async (req, res) => { return res.json({ ok: true }); } try { - connect(); + connect().catch(err => { + state.auth = 'error'; + state.lastError = err.message; + state.verificationUrl = null; + state.userCode = null; + }); res.json({ ok: true }); } catch (err) { state.auth = 'error'; diff --git a/src/tickets.js b/src/tickets.js new file mode 100644 index 0000000..0b85a25 --- /dev/null +++ b/src/tickets.js @@ -0,0 +1,104 @@ +const axios = require('axios'); + +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; +} + +function getRelevantTicketTime(ticket) { + return toMillis(ticket.completed_time) + || toMillis(ticket.resolved_time) + || toMillis(ticket.last_updated_time) + || null; +} + +async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) { + if (!accessToken) return; + + try { + const windowStart = Date.now() - windowMinutes * 60 * 1000; + + const listInfo = { + list_info: { + row_count: 200, + sort_field: 'last_updated_time', + sort_order: 'desc', + fields_required: [ + 'display_id', + 'subject', + 'status', + 'completed_time', + 'resolved_time', + 'last_updated_time', + 'technician' + ], + search_criteria: [ + { + field: 'last_updated_time', + condition: 'greater_than', + value: windowStart.toString() + } + ] + } + }; + + 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) { + 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 closedAt = getRelevantTicketTime(ticket); + 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, + subject: ticket.subject, + technician, + status: statusName || null, + closedAt, + closedDisplay: ticket.completed_time?.display_value + || ticket.resolved_time?.display_value + || ticket.last_updated_time?.display_value + || null + }); + } + + const cutoff = Date.now() - 12 * 60 * 60 * 1000; + for (const [key, ticket] of tickets) { + if (ticket.closedAt < cutoff) tickets.delete(key); + } + + state.lastCheck = Date.now(); + state.lastError = null; + } catch (err) { + const detail = err.response?.data; + state.lastError = detail?.response_status?.messages?.[0]?.message + || (typeof detail === 'string' ? detail : null) + || err.message; + state.lastCheck = Date.now(); + console.error('Error: ', detail || err.message); + } +} + +module.exports = { + fetchClosedTickets +}; \ No newline at end of file