// src/integrations/serviceChannel/client.js import axios from 'axios'; import { Mutex } from 'async-mutex'; import qs from 'querystring'; import { loadSecrets } from '../../config/secrets.js'; import { logger } from '../../utils/logger.js'; const secrets = loadSecrets(); const mutex = new Mutex(); let cachedToken = null; let tokenExpiresAt = 0; // Short-lived cache of the LAST token fetch failure. When SC credentials are // invalid/expired, a single approval webhook can otherwise trigger 6+ token // fetches (one per SC call in the flow), each hitting the OAuth endpoint and // spamming the log. Cache the failure for a short window and fast-fail without // hitting SC again. let lastTokenError = null; let lastTokenErrorAt = 0; const TOKEN_FAIL_CACHE_MS = 30 * 1000; // 30 s // ────────────────────────────────────────────── // Dedicated axios instance for ServiceChannel // ────────────────────────────────────────────── export const scAxios = axios.create({ baseURL: secrets.serviceChannel.baseUrl, timeout: 60000, 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; }); /** * Best-effort short summary of an axios error's response body for log lines. * SC 4xx bodies are usually JSON like { "Message": "...", "ModelState": {...} } * or plain text. We cap the length so a stack trace doesn't blow up the log. */ function summarizeAxiosErrorBody(error) { const data = error?.response?.data; if (data == null) return null; let s; if (typeof data === 'string') { s = data; } else if (Buffer.isBuffer(data)) { s = data.toString('utf8'); } else { try { s = JSON.stringify(data); } catch { s = String(data); } } s = s.replace(/\s+/g, ' ').trim(); return s.length > 500 ? s.substring(0, 500) + `…(${s.length} chars)` : s; } scAxios.interceptors.response.use( response => response, async error => { if (error.response?.status === 401) { logger('scAxios', '401 detected → forcing token refresh'); 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); } } // For every non-401 4xx/5xx, enrich the error message with SC's response // body preview + the HTTP method+path so downstream loggers get the *why* // (SC returns detailed ModelState / Message JSON on 400s) instead of the // generic axios "Request failed with status code 400". const status = error?.response?.status; if (status && status !== 401) { const method = String(error?.config?.method || '').toUpperCase(); const url = error?.config?.url || ''; const body = summarizeAxiosErrorBody(error); const suffix = ` [${method} ${url} → ${status}${body ? ` body=${body}` : ''}]`; // Keep the original axios message but append ours so nothing breaks // that greps for the classic "Request failed with status code XXX". if (typeof error.message === 'string' && !error.message.includes(suffix)) { error.message = error.message + suffix; } } return Promise.reject(error); } ); // ────────────────────────────────────────────── // Token management – cached + mutex-protected // ────────────────────────────────────────────── export async function getServiceChannelToken(forceRefresh = false) { if (!secrets.serviceChannel.clientId || !secrets.serviceChannel.clientSecret) { throw new Error( 'ServiceChannel credentials are not configured. ' + 'Set SC_CLIENT_ID, SC_CLIENT_SECRET, SC_USERNAME, and SC_PASSWORD environment variables.' ); } const release = await mutex.acquire(); // Fast-fail if the last fetch just failed. Prevents a cascade of retries // (and OAuth calls) when credentials are broken. Handled OUTSIDE the // try/catch so we don't double-log or double-wrap the error message. { const now = Date.now(); if (!forceRefresh && lastTokenError && (now - lastTokenErrorAt) < TOKEN_FAIL_CACHE_MS) { release(); throw new Error(lastTokenError); } } try { const now = Date.now(); if (!forceRefresh && cachedToken && now < tokenExpiresAt) { return cachedToken; } logger('getServiceChannelToken', 'Fetching new token'); const basicAuth = Buffer.from( `${secrets.serviceChannel.clientId}:${secrets.serviceChannel.clientSecret}` ).toString('base64'); const response = await axios.post( secrets.serviceChannel.oauthUrl, qs.stringify({ grant_type: 'password', username: secrets.serviceChannel.username, password: secrets.serviceChannel.password, }), { 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 lastTokenError = null; lastTokenErrorAt = 0; logger('getServiceChannelToken', `New token acquired (expires in ~${expires_in / 60} min)`); return access_token; } catch (err) { const msg = err.response ? `${err.response.status} – ${JSON.stringify(err.response.data)}` : err.message; // Cache the failure so subsequent calls in the burst fail fast (no more // OAuth hits) instead of each retrying and spamming the log. const wrapped = `ServiceChannel token fetch failed: ${msg}`; lastTokenError = wrapped; lastTokenErrorAt = Date.now(); logger('getServiceChannelToken', `Failed: ${msg}`); // ServiceChannel returns HTTP 400 with the literal string // "Object reference not set to an instance of an object." when its OAuth // endpoint receives credentials that don't map to a user (rotated password, // disabled client, etc.). Surface a clear hint the first time this happens // so it's obvious in the log what needs fixing (rotate SC_PASSWORD / // SC_CLIENT_SECRET in .env). if ( err.response?.status === 400 && typeof err.response.data === 'string' && err.response.data.toLowerCase().includes('object reference not set') ) { logger( 'getServiceChannelToken', 'HINT: 400 "Object reference not set..." from SC OAuth almost always ' + 'means SC_CLIENT_ID/SC_CLIENT_SECRET/SC_USERNAME/SC_PASSWORD are stale. ' + 'Rotate credentials in ServiceChannel and update .env, then restart.', 'warn' ); } throw new Error(wrapped); } finally { release(); } } // ────────────────────────────────────────────── // Simple health-check / token validation // ────────────────────────────────────────────── export async function validateToken() { try { await scAxios.get('/workorders?$top=1'); return true; } catch (err) { logger('validateToken', `Token validation failed: ${err.message}`); return false; } } // src/integrations/serviceChannel/client.js // ... your existing code ... /** * Get AV tickets that have been in "COMPLETED" + "CONFIRMED" status for at least X days * Used for automatic Webex space cleanup */ export async function getTicketsReadyForSpaceCleanup(daysAfterCompletion = 7) { console.log(`[SC-CLIENT] Checking for tickets ready for cleanup (${daysAfterCompletion} days after completion)`); try { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysAfterCompletion); const cutoffStr = cutoffDate.toISOString().split('T')[0]; // YYYY-MM-DD const response = await scAxios.get('/workorders', { params: { 'trade': 'Audio', // Change to 'Audio & Video' if needed 'status': 'Completed' // Primary status } }); const tickets = response.data?.value || response.data || []; console.log(`Completed WorkOrders: ${tickets.length}`) // Strict filter: only Completed + Confirmed (or Completed + Completed) const readyTickets = tickets.filter(ticket => { const primary = ticket.Status?.Primary?.toUpperCase(); const extended = ticket.Status?.Extended?.toUpperCase(); return primary === 'COMPLETED' && (extended === 'CONFIRMED' || extended === 'COMPLETED'); }); console.log(`[SC-CLIENT] Found ${readyTickets.length} tickets ready for space cleanup`); return readyTickets.map(ticket => ({ workOrderId: ticket.Id, woNumber: ticket.WorkorderNumber || ticket.Id, primaryStatus: ticket.Status?.Primary, extendedStatus: ticket.Status?.Extended, updatedDate: ticket.UpdatedDate, description: ticket.Description?.substring(0, 100) || '' })); } catch (err) { console.error('[SC-CLIENT] Error fetching tickets for cleanup:', err.message); if (err.response) { console.log('[SC-CLIENT] Response status:', err.response.status); console.log('[SC-CLIENT] Response body:', JSON.stringify(err.response.data, null, 2)); } return []; } } /** * Get current status of a specific work order */ export async function getWorkOrderStatus(woId) { try { const response = await scAxios.get(`/workorders/${woId}`, { params: { $select: 'Id,WorkorderNumber,Status,UpdatedDate,Description' } }); const ticket = response.data; return { workOrderId: ticket.Id, woNumber: ticket.WorkorderNumber || ticket.Id, primaryStatus: ticket.Status?.Primary, extendedStatus: ticket.Status?.Extended, updatedDate: ticket.UpdatedDate, description: ticket.Description?.substring(0, 100) || '' }; } catch (err) { console.error(`[SC-CLIENT] Error getting status for WO ${woId}:`, err.message); return null; } } export default scAxios; // ────────────────────────────────────────────── // Resilient fetch helper (for bulk operations) // ────────────────────────────────────────────── const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** * GET with retry for rate limits (429) and transient server errors (5xx). * * - Uses the shared scAxios instance (automatic token + 401 refresh). * - Respects `Retry-After` header when the server provides one. * - Exponential backoff + jitter to avoid thundering herd. * - Safe default: 5 retries, starting ~750ms base delay. * * Use this (instead of raw scAxios.get) for any high-volume or bulk calls. */ export async function fetchWithRetry( url, config = {}, { maxRetries = 5, baseDelayMs = 750 } = {} ) { let attempt = 0; while (true) { try { return await scAxios.get(url, config); } catch (err) { const status = err.response?.status; const isRetryable = status === 429 || (status >= 500 && status < 600); if (isRetryable && attempt < maxRetries) { const retryAfterHeader = err.response?.headers?.['retry-after']; let delayMs = baseDelayMs * Math.pow(2, attempt); if (retryAfterHeader) { const parsed = parseInt(retryAfterHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { delayMs = parsed * 1000; } } // Jitter: 75%–125% of calculated delay (prevents synchronized retries) delayMs *= 0.75 + Math.random() * 0.5; delayMs = Math.min(Math.max(300, Math.round(delayMs)), 30000); logger( 'sc:fetchRetry', `${status} on ${url} (attempt ${attempt + 1}/${maxRetries}) → retry in ${delayMs}ms` ); await sleep(delayMs); attempt++; continue; } // Non-retryable or exhausted retries — let caller handle throw err; } } } export { sleep }; // ────────────────────────────────────────────── // New: Proposal & NTE approval support (for WAITING FOR APPROVAL webhooks + Adaptive Card flow) // ────────────────────────────────────────────── /** * Fetch proposals associated with a work order. * Now uses the proper/recommended endpoint: * GET /proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber={woId} * (The old /workorders/{id}/proposals had reliability issues.) */ export async function getWorkOrderProposals(woId) { return getProposalsAssociatedWithWorkOrder(woId); } /** * Lightweight fetch for NTE + basic status (no full notes). */ export async function getWorkOrderForNte(woId) { if (!woId) return null; try { const res = await fetchWithRetry(`/workorders/${woId}`, { params: { $select: 'Id,WorkorderNumber,Nte,Status,LocationName,LocationStoreId' }, timeout: 30000, }); return res.data; } catch (err) { logger('sc:getForNte', `Failed for WO ${woId}: ${err.message}`, 'warn'); return null; } } /** * Update the Nte (Not-To-Exceed) value on a work order. * Uses PATCH on the root workorder resource. */ export async function updateWorkOrderNte(woId, nteValue) { const nte = Number(nteValue); if (!woId || !Number.isFinite(nte)) { throw new Error('woId and numeric nteValue are required'); } await scAxios.patch(`/workorders/${woId}`, { Nte: nte }); logger('sc:updateNte', `WO ${woId} Nte set to ${nte}`); } /** * DEPRECATED. ServiceChannel v3 has no public POST /workorders/{id}/notes * endpoint, so this call always returns 400. Notes must be attached to a * status/approve action (via the `Note` / `Comments` / `ReasonString` fields * on those requests). Left here only to preserve the export shape; will be * removed once no external code references it. * * @deprecated pass audit text via `Comments`/`ReasonString` on approveProposal. */ export async function addApprovalNote(woId, text) { logger( 'sc:addNote', `addApprovalNote() is deprecated and does nothing (SC has no POST /workorders/${woId}/notes). ` + `Attach note text via /approve or /status. Discarded text: "${String(text).substring(0, 80)}"`, 'warn' ); } /** * Fetch a specific proposal by its ID/Number (parsed from work order notes like "Proposal #94434 has been created"). * Tries common SC v3 endpoints. Returns the proposal object which should include charges, Total/Amount, * and itemized list (Items / LineItems / ProposalItems) with parts + labor breakout. */ export async function getProposal(proposalId) { if (!proposalId) return null; const pid = String(proposalId).trim(); // Common patterns for ServiceChannel proposal lookup const urlAttempts = [ `/proposals/${pid}`, `/proposals?proposalNumber=${pid}`, `/proposals?Number=${pid}`, `/proposals?Id=${pid}`, // Fallback: search within workorders if we had woId, but here we use direct ]; for (const url of urlAttempts) { try { const res = await fetchWithRetry(url, { timeout: 30000 }); let data = res.data; if (data?.value && Array.isArray(data.value)) { data = data.value[0] || null; } if (data && (data.Id || data.Number || data.ProposalNumber)) { logger('sc:getProposal', `Successfully fetched proposal ${pid} via ${url}`); return data; } } catch (err) { // If the token itself is broken, all remaining URL attempts will fail // the same way — abort the loop immediately instead of hammering. if (err.message && err.message.startsWith('ServiceChannel token fetch failed')) { logger('sc:getProposal', `Aborting proposal lookup for ${pid}: ${err.message}`, 'warn'); return null; } // try next if (err.response && err.response.status !== 404) { logger('sc:getProposal', `Attempt ${url} failed for ${pid}: ${err.message}`, 'warn'); } } } logger('sc:getProposal', `Could not locate proposal ${pid} after multiple attempts`, 'warn'); return null; } /** * Proper way to find proposals associated with a work order (per ServiceChannel API). * Uses GET /proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber={woId} * Returns array of lightweight proposal refs { ID, ProposalNumber, Status, ... } */ export async function getProposalsAssociatedWithWorkOrder(woId) { if (!woId) return []; try { const res = await fetchWithRetry( `/proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber=${woId}`, { timeout: 30000 } ); return res.data || []; } catch (err) { logger('sc:getAssociatedProposals', `Failed for WO ${woId}: ${err.message}`, 'warn'); return []; } } /** * Fetch full proposal details using the OData endpoint. * GET /odata/proposals?$filter=Id eq {proposalId} * This returns the rich object with AmountCategories (Materials, Installation Labor, etc.), * Amount (total), Description, Status, Recommendation, etc. */ export async function getProposalByIdOdata(proposalId) { if (!proposalId) return null; try { const res = await fetchWithRetry( `/odata/proposals?$filter=Id eq ${proposalId}`, { timeout: 30000 } ); const data = res.data; if (data?.value && Array.isArray(data.value) && data.value.length > 0) { logger('sc:getProposalOdata', `Fetched OData details for proposal ${proposalId}`); return data.value[0]; } return null; } catch (err) { logger('sc:getProposalOdata', `Failed for ${proposalId}: ${err.message}`, 'warn'); return null; } } /** * Approve a specific proposal using the official endpoint. * PUT /proposals/{proposalId}/approve * This properly approves the proposal (instead of just updating WO Nte + note). * Body fields as per ServiceChannel API (Comments, ReasonString, AttachmentsToWO, etc.). */ export async function approveProposal(proposalId, body) { if (!proposalId) { throw new Error('proposalId is required to approve'); } await scAxios.put(`/proposals/${proposalId}/approve`, body); logger('sc:approveProposal', `Successfully approved proposal ${proposalId}`); } /** * Fetch proposals that must be rejected before approving a revised proposal. * GET /proposals/GetProposalsToReject?trackingNumber={woId} */ export async function getProposalsToReject(woId) { if (!woId) return []; try { const res = await fetchWithRetry( `/proposals/GetProposalsToReject?trackingNumber=${woId}`, { timeout: 30000 } ); const data = res.data; if (Array.isArray(data)) return data; if (data?.value && Array.isArray(data.value)) return data.value; return []; } catch (err) { // 502 "Proposals to Reject not found" is normal when no superseded proposals exist if (err.response?.status === 400 || err.response?.status === 502) { logger('sc:getProposalsToReject', `No proposals to reject for WO ${woId}`, 'info'); return []; } logger('sc:getProposalsToReject', `Failed for WO ${woId}: ${err.message}`, 'warn'); return []; } } let _rejectionReasonsCache = null; let _rejectionReasonsCachedAt = 0; const REJECTION_REASONS_TTL_MS = 60 * 60 * 1000; // 1 hour /** SC returns RejectionReasons as { "1": "desc", "2": "..." } — not an array. */ function _parseRejectionReasonsResponse(data) { if (Array.isArray(data)) return data; if (data?.value && Array.isArray(data.value)) return data.value; if (data && typeof data === 'object') { return Object.entries(data).map(([id, desc]) => ({ Id: Number(id), Description: typeof desc === 'string' ? desc : String(desc ?? ''), })).filter((r) => Number.isFinite(r.Id) && r.Id > 0); } return []; } /** * Fetch valid proposal rejection reason codes. * GET /proposals/RejectionReasons */ export async function getProposalRejectionReasons() { const now = Date.now(); if (_rejectionReasonsCache && (now - _rejectionReasonsCachedAt) < REJECTION_REASONS_TTL_MS) { return _rejectionReasonsCache; } try { const res = await fetchWithRetry('/proposals/RejectionReasons', { timeout: 30000 }); const reasons = _parseRejectionReasonsResponse(res.data); _rejectionReasonsCache = reasons; _rejectionReasonsCachedAt = now; return reasons; } catch (err) { logger('sc:getRejectionReasons', `Failed: ${err.message}`, 'warn'); return _rejectionReasonsCache || []; } } const REJECT_REASON_KEYWORDS = ['revised', 'superseded', 'replacement', 'replaced', 'updated', 'scope']; /** Default when env/keyword lookup fails — "Change in scope of work" on most tenants. */ const DEFAULT_REJECT_REASON_ID = 2; /** * Resolve RejectReasonCodeId: env override, keyword match on SC reasons, or sensible default. */ export async function resolveRejectReasonCodeId() { const envId = process.env.SC_PROPOSAL_REJECT_REASON_ID; if (envId != null && envId !== '') { const n = Number(envId); if (Number.isFinite(n) && n > 0) return n; } const reasons = await getProposalRejectionReasons(); if (!reasons.length) { return DEFAULT_REJECT_REASON_ID; } for (const kw of REJECT_REASON_KEYWORDS) { const match = reasons.find((r) => { const text = `${r.Description || ''} ${r.Name || ''} ${r.Reason || ''}`.toLowerCase(); return text.includes(kw); }); if (match) { const id = match.Id ?? match.RejectReasonCodeId ?? match.ReasonCodeId; if (id != null) { return Number(id); } } } const first = reasons[0]; const resolved = Number(first?.Id ?? first?.RejectReasonCodeId ?? first?.ReasonCodeId ?? DEFAULT_REJECT_REASON_ID); return resolved; } /** * Reject a specific proposal. * PUT /proposals/{proposalId}/reject */ export async function rejectProposal(proposalId, body) { if (!proposalId) { throw new Error('proposalId is required to reject'); } await scAxios.put(`/proposals/${proposalId}/reject`, body); logger('sc:rejectProposal', `Successfully rejected proposal ${proposalId}`); } /** * Fetch proposal details by display number via OData. * Used when associated-proposals list is empty but the webhook note parsed a proposal #. */ export async function getProposalByNumberOdata(proposalNumber) { if (proposalNumber == null || proposalNumber === '') return null; const num = String(proposalNumber).trim(); const filters = [`Number eq ${num}`, `Number eq '${num}'`]; for (const filter of filters) { try { const res = await fetchWithRetry( `/odata/proposals?$filter=${encodeURIComponent(filter)}`, { timeout: 30000 } ); const row = res.data?.value?.[0]; if (row) { logger('sc:getProposalOdata', `Fetched OData details for proposal #${num}`); return row; } } catch (err) { logger('sc:getProposalOdata', `Number filter "${filter}" failed for #${num}: ${err.message}`, 'warn'); } } return null; } /** * OData fetch with optional $expand=AmountCategories (may surface nested detail on some tenants). * Note: $expand=AmountCategories fails on many SC tenants — prefer getProposalByIdOdata. */ export async function getProposalByIdOdataExpanded(proposalId) { if (!proposalId) return null; try { const res = await fetchWithRetry( `/odata/proposals?$filter=Id eq ${proposalId}&$expand=AmountCategories`, { timeout: 30000 } ); const data = res.data; if (data?.value?.length > 0) { logger('sc:getProposalOdata', `Fetched expanded OData for proposal ${proposalId}`); return data.value[0]; } return null; } catch (err) { logger('sc:getProposalOdata', `Expanded fetch failed for ${proposalId}: ${err.message}`, 'warn'); return null; } }