Replace /completed with /confirmed that tries SC CONFIRMED then falls back to SC notes and ServChan close-out records when status is locked. Post invoice approval cards on PDF attach, track close-outs for cleanup, and add WO-space /addNote. Co-authored-by: Cursor <cursoragent@cursor.com>
790 lines
27 KiB
JavaScript
790 lines
27 KiB
JavaScript
// 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,Provider,ProviderName,ProviderId,IsInvoiced,Invoice,ApprovalCode,Category',
|
||
},
|
||
});
|
||
|
||
const ticket = response.data;
|
||
const invoiceId = ticket.Invoice?.Id ?? null;
|
||
let invoiceTotal = ticket.Invoice?.InvoiceTotal ?? ticket.Invoice?.Total ?? null;
|
||
|
||
if (invoiceId && (invoiceTotal == null || Number(invoiceTotal) === 0)) {
|
||
const inv = await getInvoice(invoiceId);
|
||
if (inv) {
|
||
invoiceTotal = inv.InvoiceTotal ?? inv.Total ?? invoiceTotal;
|
||
}
|
||
}
|
||
|
||
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) || '',
|
||
providerName: ticket.Provider?.Name || ticket.ProviderName || '',
|
||
providerId: ticket.Provider?.Id ?? ticket.ProviderId ?? null,
|
||
isInvoiced: ticket.IsInvoiced === true,
|
||
invoiceId,
|
||
invoiceNumber: ticket.Invoice?.Number ?? null,
|
||
invoiceTotal: invoiceTotal != null ? Number(invoiceTotal) : null,
|
||
approvalCode: ticket.ApprovalCode ?? '',
|
||
category: ticket.Category ?? '',
|
||
};
|
||
} catch (err) {
|
||
console.error(`[SC-CLIENT] Error getting status for WO ${woId}:`, err.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update work order primary/extended status.
|
||
* PUT /workorders/{woId}/status
|
||
*/
|
||
export async function updateWorkOrderStatus(woId, { primary, extended, note }) {
|
||
if (!woId || !primary) {
|
||
throw new Error('woId and primary status are required');
|
||
}
|
||
|
||
const body = {
|
||
Status: {
|
||
Primary: primary,
|
||
Extended: extended || '',
|
||
},
|
||
Note: String(note || '').trim(),
|
||
};
|
||
|
||
await scAxios.put(`/workorders/${woId}/status`, body);
|
||
logger(
|
||
'sc:updateWorkOrderStatus',
|
||
`WO ${woId} status → ${primary}${extended ? ` / ${extended}` : ''}`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Approve an invoice in ServiceChannel.
|
||
* PUT /invoices/{invoiceId}/approve
|
||
*/
|
||
export async function approveInvoice(invoiceId, { approvalCode = '', comments = '', category = '' } = {}) {
|
||
if (!invoiceId) {
|
||
throw new Error('invoiceId is required to approve');
|
||
}
|
||
|
||
const params = new URLSearchParams();
|
||
params.set('approvalCode', String(approvalCode ?? ''));
|
||
params.set('comments', String(comments ?? ''));
|
||
params.set('category', String(category ?? ''));
|
||
|
||
await scAxios.put(`/invoices/${invoiceId}/approve?${params.toString()}`);
|
||
logger('sc:approveInvoice', `Approved invoice ${invoiceId}`);
|
||
}
|
||
|
||
/**
|
||
* Reject an invoice in ServiceChannel.
|
||
* PUT /invoices/{invoiceId}/reject
|
||
*/
|
||
export async function rejectInvoice(invoiceId, { comments = '', isNotifyProvider = true } = {}) {
|
||
if (!invoiceId) {
|
||
throw new Error('invoiceId is required to reject');
|
||
}
|
||
|
||
const params = new URLSearchParams();
|
||
params.set('comments', String(comments ?? ''));
|
||
params.set('isNotifyProvider', String(isNotifyProvider));
|
||
|
||
await scAxios.put(`/invoices/${invoiceId}/reject?${params.toString()}`);
|
||
logger('sc:rejectInvoice', `Rejected invoice ${invoiceId}`);
|
||
}
|
||
|
||
/**
|
||
* Fetch invoice details by id.
|
||
* GET /invoices/{invoiceId}
|
||
*/
|
||
export async function getInvoice(invoiceId) {
|
||
if (!invoiceId) return null;
|
||
try {
|
||
const res = await fetchWithRetry(`/invoices/${invoiceId}`, { timeout: 30000 });
|
||
return res.data;
|
||
} catch (err) {
|
||
logger('sc:getInvoice', `Failed for invoice ${invoiceId}: ${err.message}`, 'warn');
|
||
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}`);
|
||
}
|
||
|
||
/**
|
||
* Add a note/comment to a work order.
|
||
* POST /workorders/{workorderId}/notes — body requires `Note` (string).
|
||
*/
|
||
export async function addWorkOrderNote(woId, noteText, options = {}) {
|
||
const text = String(noteText || '').trim();
|
||
if (!woId || !text) {
|
||
throw new Error('woId and noteText are required');
|
||
}
|
||
|
||
const body = {
|
||
Note: text,
|
||
ActionRequired: options.actionRequired === true,
|
||
};
|
||
if (options.mailedTo) body.MailedTo = options.mailedTo;
|
||
|
||
await scAxios.post(`/workorders/${woId}/notes`, body);
|
||
logger('sc:addWorkOrderNote', `Added note to WO ${woId}`);
|
||
}
|
||
|
||
/**
|
||
* DEPRECATED. Use addWorkOrderNote() instead.
|
||
*
|
||
* @deprecated
|
||
*/
|
||
export async function addApprovalNote(woId, text) {
|
||
return addWorkOrderNote(woId, text);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|