diff --git a/package-lock.json b/package-lock.json
index 0bcfd87..f23f9b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,7 +6,7 @@
"": {
"dependencies": {
"axios": "^1.13.2",
- "dotenv": "^17.2.3"
+ "dotenv": "^17.4.2"
}
},
"node_modules/asynckit": {
@@ -61,9 +61,9 @@
}
},
"node_modules/dotenv": {
- "version": "17.2.3",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
- "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
diff --git a/package.json b/package.json
index 15f3cd4..3d2a034 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"dependencies": {
"axios": "^1.13.2",
- "dotenv": "^17.2.3"
+ "dotenv": "^17.4.2"
}
}
diff --git a/src/public/index.html b/src/public/index.html
new file mode 100644
index 0000000..f8af0ee
--- /dev/null
+++ b/src/public/index.html
@@ -0,0 +1,446 @@
+
+
+
+
+
+Closed ticket board
+
+
+
+
+
+
+
+
+
+ Closed ticket board
+
+ Not connected
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/server.js b/src/server.js
new file mode 100644
index 0000000..b830c77
--- /dev/null
+++ b/src/server.js
@@ -0,0 +1,222 @@
+
+require('dotenv').config({ quiet: true });
+
+const path = require('path');
+const express = require('express');
+const axios = require('axios');
+
+const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
+const PORT = process.env.PORT || 3000;
+
+if (!CLIENT_ID || !CLIENT_SECRET) {
+ console.error('Client id and secret missing from .env');
+ process.exit(1);
+}
+
+const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
+const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
+
+const WINDOW_MINUTES = 10;
+const CHECK_MINUTES = 1;
+
+let accessToken = null;
+
+const state = {
+ auth: 'disconnected',
+ verificationUrl: null,
+ userCode: null,
+ lastCheck: null,
+ lastError: null
+};
+
+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('The authorization code expired. Connect 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;
+}
+
+async function fetchClosedTickets() {
+ if (!accessToken) return;
+
+ try {
+ const windowStart = Date.now() - WINDOW_MINUTES * 60 * 1000;
+
+ const listInfo = {
+ list_info: {
+ row_count: 50,
+ sort_field: 'completed_time',
+ sort_order: 'desc',
+ fields_required: ['display_id', 'subject', 'status', 'completed_time', 'technician'],
+ search_criteria: [
+ {
+ field: 'status.internal_name',
+ condition: 'is',
+ values: statuses.length ? statuses : ['Closed']
+ },
+ {
+ field: 'completed_time',
+ condition: 'greater_than',
+ value: windowStart.toString(),
+ logical_operator: 'and'
+ }
+ ]
+ }
+ };
+
+ 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 closedAt = toMillis(ticket.completed_time);
+ 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: ticket.status ? ticket.status.name : null,
+ closedAt,
+ closedDisplay: ticket.completed_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);
+ }
+}
+
+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);
+ } 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();
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.get('/api/state', (req, res) => {
+ res.json({
+ ...state,
+ windowMinutes: WINDOW_MINUTES,
+ checkMinutes: CHECK_MINUTES,
+ watching: technicians,
+ serverTime: Date.now(),
+ tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
+ });
+});
+
+app.post('/api/connect', async (req, res) => {
+ if (state.auth === 'pending' || state.auth === 'connected') {
+ return res.json({ ok: true });
+ }
+ try {
+ connect();
+ res.json({ ok: true });
+ } catch (err) {
+ state.auth = 'error';
+ state.lastError = err.message;
+ res.status(500).json({ ok: false, error: err.message });
+ }
+});
+
+app.post('/api/check', async (req, res) => {
+ await fetchClosedTickets();
+ res.json({ ok: true });
+});
+
+app.listen(PORT, () => {
+ console.log(`Open http://localhost:${PORT}`);
+});
diff --git a/src/test.js b/src/test.js
deleted file mode 100644
index 4013add..0000000
--- a/src/test.js
+++ /dev/null
@@ -1,152 +0,0 @@
-//I barely understand this code, so don't ask me to explain it.
-// Or do just know it will be very basic explanations.
-
-require('dotenv').config({ quiet: true });
-const axios = require('axios');
-
-const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
-
-if (!CLIENT_ID || !CLIENT_SECRET) {
- console.error('Client id and secret missing from .env');
- process.exit(1);
-}
-
-const technicians = TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : [];
-const statuses = STATUS_NAMES ? STATUS_NAMES.split(',') : [];
-
-let accessToken = null;
-
-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;
-}
-
-async function pollForToken(deviceCode, interval) {
- const pollParams = new URLSearchParams({
- client_id: CLIENT_ID,
- client_secret: CLIENT_SECRET,
- code: deviceCode,
- grant_type: 'device_token'
- });
-
- return new Promise((resolve, reject) => {
- const pollInterval = setInterval(async () => {
- try {
- const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
- if (res.data.access_token) {
- clearInterval(pollInterval);
- accessToken = res.data.access_token;
- console.log('Authorization successful');
- resolve();
- }
- } catch (err) {
-
- }
- }, (interval + 500));
- });
-}
-
-async function fetchClosedTickets() {
- if (!accessToken) return;
-
- try {
- const tenMinutesAgo = Date.now() - (10 * 60 * 1000);
-
- const listInfo = {
- list_info: {
- row_count: 50,
- sort_field: "completed_time",
- sort_order: "desc",
- fields_required: ["display_id", "subject", "status", "completed_time", "technician"],
- search_criteria: [
- {
- field: "status.internal_name",
- condition: "is",
- values: statuses.map(status => status.trim())
- },
- {
- field: "completed_time",
- condition: "greater_than",
- value: tenMinutesAgo.toString(),
- logical_operator: "and"
- }
- ]
- }
- };
-
- 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'
- }
- }
- );
-
- let tickets = response.data?.requests || [];
-
- const trimmedTechnicians = technicians.map(t => t.trim());
-
- tickets = tickets.filter(ticket => {
- if (!ticket.completed_time){return false;}
- const completedTime = typeof ticket.completed_time === 'object'
- ? ticket.completed_time.value
- : ticket.completed_time;
- if (completedTime <= tenMinutesAgo){return false;}
-
- const technicianName = ticket.technician ? ticket.technician.name : null;
- if (!technicianName){return false;}
- return trimmedTechnicians.includes(technicianName);
- });
-
- console.log(`\nCheck ran at ${new Date().toLocaleTimeString()}`);
-
-
-
-
- if (tickets.length === 0) {
- console.log("No tickets closed in the last 10 minutes.");
- } else {
- console.log(`Found ${tickets.length} tickets closed since ${new Date(tenMinutesAgo).toLocaleTimeString()}:`);
- console.log('---------------------------------------------------------');
-
- tickets.forEach((ticket) => {
- const ticketId = ticket.display_id;
- const subject = ticket.subject;
- const tech = ticket.technician ? ticket.technician.name : 'Unassigned';
- const closedAt = ticket.completed_time ? ticket.completed_time.display_value : 'N/A';
-
- console.log(`ID: ${ticketId} - ${subject}`);
- console.log(`Technician: ${tech}`);
- console.log(`Closed At: ${closedAt}`);
- console.log('---------------------------------------------------------');
- });
- }
-
- } catch (err) {
- console.error('Error: ', err.response?.data || err.message);
- }
-}
-
-
-async function main() {
- const deviceData = await getDeviceCode();
- console.log('Open this URL and authorize:', deviceData.verification_uri_complete);
- console.log('Code: ', deviceData.user_code);
-
- await pollForToken(deviceData.device_code, deviceData.interval);
-
- setInterval(fetchClosedTickets, 1 * 60 * 1000); //Only change the first number the first is the amount of minutes between each run
- fetchClosedTickets();
-}
-
-main().catch(err => console.error(err));