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