// src/integrations/serviceChannel/client.js import axios from 'axios'; import { Mutex } from 'async-mutex'; import { logger } from '../../utils/logger.js'; const mutex = new Mutex(); let cachedToken = null; let tokenExpiresAt = 0; // ────────────────────────────────────────────── // Dedicated axios instance for ServiceChannel // ────────────────────────────────────────────── export const scAxios = axios.create({ baseURL: process.env.SC_BASE_URL, timeout: 15000, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); // Automatic token injection + refresh on 401 scAxios.interceptors.request.use(async (cfg) => { if (!cfg.headers.Authorization) { const token = await getServiceChannelToken(); cfg.headers.Authorization = `Bearer ${token}`; } return cfg; }); scAxios.interceptors.response.use( response => response, async (error) => { if (error.response?.status === 401) { logger('servicechannel:client', '401 detected → forcing token refresh', 'warn'); await getServiceChannelToken(true); // force refresh // Retry once with new token const originalRequest = error.config; if (!originalRequest._retry) { originalRequest._retry = true; originalRequest.headers.Authorization = `Bearer ${cachedToken}`; return scAxios(originalRequest); } } return Promise.reject(error); } ); // ────────────────────────────────────────────── // Token management – cached + mutex-protected // ────────────────────────────────────────────── export async function getServiceChannelToken(forceRefresh = false) { const release = await mutex.acquire(); try { const now = Date.now(); if (!forceRefresh && cachedToken && now < tokenExpiresAt) { return cachedToken; } logger('servicechannel:client', 'Fetching new ServiceChannel token'); const basicAuth = Buffer.from( `${process.env.SC_CLIENT_ID}:${process.env.SC_CLIENT_SECRET}` ).toString('base64'); const response = await axios.post( process.env.SC_OAUTH_URL, new URLSearchParams({ grant_type: 'password', username: process.env.SC_USERNAME, password: process.env.SC_PASSWORD, }).toString(), { headers: { 'Authorization': `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded', }, timeout: 10000, } ); const { access_token, expires_in } = response.data; cachedToken = access_token; tokenExpiresAt = now + (expires_in * 1000) - 300_000; // refresh 5 min early logger('servicechannel:client', `New token acquired (expires in ~${Math.round(expires_in / 60)} minutes)`); return access_token; } catch (err) { const msg = err.response ? `${err.response.status} – ${JSON.stringify(err.response.data)}` : err.message; logger('servicechannel:client', `Token fetch failed: ${msg}`, 'error'); throw new Error(`ServiceChannel token fetch failed: ${msg}`); } finally { release(); } } // ────────────────────────────────────────────── // Simple health-check / token validation // ────────────────────────────────────────────── export async function validateToken() { try { await scAxios.get('/workorders?$top=1'); logger('servicechannel:client', 'Token validation successful'); return true; } catch (err) { logger('servicechannel:client', `Token validation failed: ${err.message}`, 'warn'); return false; } } // ────────────────────────────────────────────── // Work Order Search Functions // ────────────────────────────────────────────── export async function searchServiceChannelAVWorkOrders(storeNumber) { logger('servicechannel:client', `Searching AV work orders for store ${storeNumber}`); try { const threeYearsAgo = new Date(); threeYearsAgo.setFullYear(threeYearsAgo.getFullYear() - 3); const fromDate = threeYearsAgo.toISOString().split('T')[0]; const storeNum = storeNumber.padStart(6, "0"); const response = await scAxios.get('/workorders', { params: { 'storeId': storeNum, 'trade': 'Audio' } }); const rawData = response.data.value || response.data || []; logger('servicechannel:client', `Found ${rawData.length} AV work orders for store ${storeNumber}`); return rawData.map(wo => ({ id: wo.Id, woNumber: wo.WorkorderNumber, summary: wo.ShortDescription || 'No summary', status: `${wo.Status?.Primary || 'Unknown'}/${wo.Status?.Extended || 'Unknown'}`, openedDate: wo.CreatedDate ? new Date(wo.CreatedDate).toLocaleDateString() : 'Unknown', totalInvoiceCost: Number(wo.Nte || 0) })); } catch (err) { logger('servicechannel:client', `Work order search error for store ${storeNumber}: ${err.message}`, 'error'); if (err.response) { logger('servicechannel:client', `Response status: ${err.response.status}`, 'error'); } return []; } } export async function getWorkOrderNotes(woId) { logger('servicechannel:client', `Fetching notes for work order ${woId}`); try { const response = await scAxios.get(`/workorders/${woId}/notes`, { params: { "paging": "1:9999" } }); const notesArray = response.data.Notes || response.data.value || response.data || []; if (!Array.isArray(notesArray)) { logger('servicechannel:client', `Notes response is not an array for WO ${woId}`, 'warn'); return []; } logger('servicechannel:client', `Retrieved ${notesArray.length} notes for WO ${woId}`); return notesArray.map(note => ({ date: note.DateCreated ? new Date(note.DateCreated).toLocaleString() : 'Unknown', text: note.NoteData || '', createdBy: note.CreatedBy || 'Unknown' })); } catch (err) { logger('servicechannel:client', `Failed to fetch notes for WO ${woId}: ${err.message}`, 'error'); return []; } } export async function getWorkOrderDetails(woId) { logger('servicechannel:client', `Fetching full details for work order ${woId}`); try { const response = await scAxios.get(`/workorders/${woId}`); return response.data; } catch (err) { logger('servicechannel:client', `Error fetching details for WO ${woId}: ${err.message}`, 'error'); return null; } } export { listWorkOrderAttachments, downloadAttachmentById, getWorkOrderAttachments } from './attachments.js'; export default scAxios;