dev #2

Merged
igodin merged 12 commits from dev into main 2026-07-27 18:37:38 +00:00
4 changed files with 219 additions and 161 deletions
Showing only changes of commit d3f3387183 - Show all commits

80
src/auth.js Normal file
View File

@ -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
};

View File

@ -398,7 +398,7 @@ function render() {
<h2>Approve the board</h2> <h2>Approve the board</h2>
<p>Open the authorization page and confirm this code matches the one shown there.</p> <p>Open the authorization page and confirm this code matches the one shown there.</p>
<div class="code">${esc(data.userCode || '...')}</div> <div class="code">${esc(data.userCode || '...')}</div>
<a class="button" href="${esc(data.verificationUrl || '#')}" target="_blank" rel="noopener">Open authorization page</a> <a class="button" href="${esc(data.verificationUrl || '#')}">Open authorization page</a>
</div>`; </div>`;
} }

View File

@ -3,10 +3,11 @@ require('dotenv').config({ quiet: true });
const path = require('path'); const path = require('path');
const express = require('express'); 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 { 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) { if (!CLIENT_ID || !CLIENT_SECRET) {
console.error('Client id and secret missing from .env'); console.error('Client id and secret missing from .env');
@ -32,176 +33,44 @@ const state = {
const tickets = new Map(); 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;
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 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);
}
}
async function connect() { 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 { try {
await pollForToken(deviceData.device_code, deviceData.interval, deviceData.expires_in); const authResult = await startAuthentication({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
state
});
if (!authResult?.accessToken) return;
accessToken = authResult.accessToken;
console.log('Authorization successful');
await fetchClosedTickets({
accessToken,
state,
tickets,
technicians,
statusSet,
windowMinutes: WINDOW_MINUTES
});
setInterval(() => {
fetchClosedTickets({
accessToken,
state,
tickets,
technicians,
statusSet,
windowMinutes: WINDOW_MINUTES
});
}, CHECK_MINUTES * 60 * 1000);
} catch (err) { } catch (err) {
state.auth = 'error'; state.auth = 'error';
state.lastError = err.message; state.lastError = err.message;
state.verificationUrl = null; state.verificationUrl = null;
state.userCode = 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(); const app = express();
@ -223,7 +92,12 @@ app.post('/api/connect', async (req, res) => {
return res.json({ ok: true }); return res.json({ ok: true });
} }
try { try {
connect(); connect().catch(err => {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
});
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
state.auth = 'error'; state.auth = 'error';

104
src/tickets.js Normal file
View File

@ -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
};