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.
This commit is contained in:
parent
6a5bfce930
commit
d3f3387183
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>
|
<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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
194
src/server.js
194
src/server.js
@ -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() {
|
async function connect() {
|
||||||
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 {
|
try {
|
||||||
const windowStart = Date.now() - WINDOW_MINUTES * 60 * 1000;
|
const authResult = await startAuthentication({
|
||||||
|
clientId: CLIENT_ID,
|
||||||
const listInfo = {
|
clientSecret: CLIENT_SECRET,
|
||||||
list_info: {
|
state
|
||||||
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 || [];
|
if (!authResult?.accessToken) return;
|
||||||
|
|
||||||
for (const ticket of found) {
|
accessToken = authResult.accessToken;
|
||||||
const statusName = ticket.status?.name || '';
|
console.log('Authorization successful');
|
||||||
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);
|
await fetchClosedTickets({
|
||||||
if (!closedAt || closedAt <= windowStart) continue;
|
accessToken,
|
||||||
|
state,
|
||||||
|
tickets,
|
||||||
|
technicians,
|
||||||
|
statusSet,
|
||||||
|
windowMinutes: WINDOW_MINUTES
|
||||||
|
});
|
||||||
|
|
||||||
const technician = ticket.technician ? ticket.technician.name : null;
|
setInterval(() => {
|
||||||
if (!technician) continue;
|
fetchClosedTickets({
|
||||||
if (technicians.length && !technicians.includes(technician)) continue;
|
accessToken,
|
||||||
|
state,
|
||||||
tickets.set(String(ticket.display_id), {
|
tickets,
|
||||||
id: ticket.display_id,
|
technicians,
|
||||||
subject: ticket.subject,
|
statusSet,
|
||||||
technician,
|
windowMinutes: WINDOW_MINUTES
|
||||||
status: statusName || null,
|
|
||||||
closedAt,
|
|
||||||
closedDisplay: ticket.completed_time?.display_value
|
|
||||||
|| ticket.resolved_time?.display_value
|
|
||||||
|| ticket.last_updated_time?.display_value
|
|
||||||
|| null
|
|
||||||
});
|
});
|
||||||
}
|
}, CHECK_MINUTES * 60 * 1000);
|
||||||
|
|
||||||
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) {
|
} 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
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