servchan/src/integrations/serviceChannel/client.js
jmcqueen cba047cb4e Add invoice approval cards, /confirmed close-out fallback, and /addNote.
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>
2026-08-25 12:59:54 -04:00

790 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
}