dev #2
80
src/auth.js
Normal file
80
src/auth.js
Normal 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
|
||||
};
|
||||
@ -398,7 +398,7 @@ function render() {
|
||||
<h2>Approve the board</h2>
|
||||
<p>Open the authorization page and confirm this code matches the one shown there.</p>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
|
||||
194
src/server.js
194
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';
|
||||
|
||||
104
src/tickets.js
Normal file
104
src/tickets.js
Normal 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
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user