ServiceChannel webhook processor, proposal approval cards, attachment auto-post, CollabSupport commands, and Docker deployment configuration. Co-authored-by: Cursor <cursoragent@cursor.com>
939 lines
32 KiB
JavaScript
939 lines
32 KiB
JavaScript
/**
|
|
* src/services/approvalService.js
|
|
*
|
|
* Handles the "WAITING FOR APPROVAL" detection + Adaptive Card (v1.3) UX.
|
|
* - Fetches proposals + current NTE from ServiceChannel.
|
|
* - Posts markdown itemization summary + approval card.
|
|
* - Processes card submits: reject superseded proposals, approve, optional NTE override.
|
|
*/
|
|
|
|
import { logger } from '../utils/logger.js';
|
|
import {
|
|
getWorkOrderForNte,
|
|
updateWorkOrderNte,
|
|
getProposalsAssociatedWithWorkOrder,
|
|
getProposalByIdOdata,
|
|
getProposalByNumberOdata,
|
|
getProposalsToReject,
|
|
resolveRejectReasonCodeId,
|
|
rejectProposal,
|
|
approveProposal,
|
|
} from '../integrations/serviceChannel/client.js';
|
|
import webexService from './webexService.js';
|
|
|
|
const CARD_DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
const _recentApprovalCards = new Map();
|
|
|
|
function _pruneDedupCache(now = Date.now()) {
|
|
for (const [k, ts] of _recentApprovalCards.entries()) {
|
|
if (now - ts > CARD_DEDUP_TTL_MS) _recentApprovalCards.delete(k);
|
|
}
|
|
}
|
|
|
|
function _extractProposalNumber(noteData) {
|
|
if (!noteData) return null;
|
|
const m = String(noteData).match(/Proposal\s*#?(\d+)/i);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
function _dedupKeys(woId, noteData) {
|
|
const keys = [`wo:${woId}:pending`];
|
|
const p = _extractProposalNumber(noteData);
|
|
if (p) keys.push(`wo:${woId}:p:${p}`);
|
|
return keys;
|
|
}
|
|
|
|
function _proposalStatusPrimary(p) {
|
|
if (!p) return '';
|
|
if (typeof p.Status === 'string') return p.Status;
|
|
return p.Status?.Primary || '';
|
|
}
|
|
|
|
function _isApproved(p) {
|
|
return _proposalStatusPrimary(p).toLowerCase() === 'approved';
|
|
}
|
|
|
|
function _proposalRefNumber(p) {
|
|
return String(p?.Number ?? p?.ProposalNumber ?? p?.ID ?? p?.Id ?? '');
|
|
}
|
|
|
|
function _proposalRefId(p) {
|
|
return p?.Id ?? p?.ID ?? null;
|
|
}
|
|
|
|
function _proposalDisplayNumber(p) {
|
|
return p?.Number ?? p?.ProposalNumber ?? _proposalRefId(p) ?? '?';
|
|
}
|
|
|
|
function _proposalDetailsUrl(proposal) {
|
|
const id = _proposalRefId(proposal);
|
|
if (!id) return null;
|
|
return `https://www.servicechannel.com/proposal/details/${id}`;
|
|
}
|
|
|
|
function _formatMoney(n) {
|
|
const v = Number(n);
|
|
return Number.isFinite(v) ? `$${v.toFixed(2)}` : '$0.00';
|
|
}
|
|
|
|
function _filterCategories(categories = []) {
|
|
return categories.filter((cat) => {
|
|
const cost = cat.TotalCost;
|
|
if (cost == null) return false;
|
|
const name = (cat.Name || '').toLowerCase();
|
|
if (name.includes('costs incurred to date')) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract line-item arrays from a proposal object (best-effort).
|
|
*/
|
|
export function extractLineItems(proposal) {
|
|
if (!proposal) return [];
|
|
const candidates = [
|
|
proposal.Items,
|
|
proposal.LineItems,
|
|
proposal.ProposalItems,
|
|
proposal.items,
|
|
proposal.Charges,
|
|
proposal.Materials,
|
|
];
|
|
for (const arr of candidates) {
|
|
if (Array.isArray(arr) && arr.length > 0) return arr;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function _lineItemPart(it) {
|
|
return it.PartNum || it.PartNumber || it.SKU || it.Code || it.Name || it.Description || '—';
|
|
}
|
|
|
|
function _lineItemQty(it) {
|
|
const q = it.Quantity ?? it.Qty ?? it.NumOfTech;
|
|
return q != null ? String(q) : '—';
|
|
}
|
|
|
|
function _lineItemUnitPrice(it) {
|
|
const p = it.UnitPrice ?? it.HourlyRate ?? it.Rate;
|
|
return p != null ? _formatMoney(p) : '—';
|
|
}
|
|
|
|
function _lineItemTotal(it) {
|
|
const t = it.Amount ?? it.Cost ?? it.Total ?? it.Value;
|
|
return t != null ? _formatMoney(t) : '—';
|
|
}
|
|
|
|
/**
|
|
* SC-invoice-style markdown summary for a proposal.
|
|
*/
|
|
export function formatProposalMarkdown(wo, proposal, context = {}) {
|
|
if (!proposal) {
|
|
return `**Proposal Approval Required** — WO-${wo?.Number || wo?.Id || '???'}\n\nNo proposal details available.`;
|
|
}
|
|
|
|
const woNum = wo?.Number || wo?.Id || '???';
|
|
const woLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${wo?.Id || woNum}`;
|
|
const store = wo?.LocationStoreId != null && wo?.LocationStoreId !== ''
|
|
? `Store ${wo.LocationStoreId}`
|
|
: (wo?.LocationName || '');
|
|
const provider = wo?.ProviderName || context.providerName || '';
|
|
const pNum = _proposalDisplayNumber(proposal);
|
|
const proposalDetailsUrl = _proposalDetailsUrl(proposal);
|
|
const pStatus = typeof proposal.Status === 'object'
|
|
? `${proposal.Status.Primary || ''}${proposal.Status.Extended ? ` | ${proposal.Status.Extended}` : ''}`
|
|
: (proposal.Status || 'Open');
|
|
const created = proposal.CreatedDate
|
|
? new Date(proposal.CreatedDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
: '';
|
|
const desc = (proposal.Description || proposal.Description2 || proposal.Comments || '').toString().trim();
|
|
const total = Number(proposal.Amount ?? proposal.Total ?? proposal.TotalAmount ?? 0);
|
|
|
|
let md = `## Proposal #${pNum}\n\n`;
|
|
if (proposalDetailsUrl) {
|
|
md += `[View full proposal details in ServiceChannel](${proposalDetailsUrl})\n\n`;
|
|
}
|
|
md += `[WO-${woNum}](${woLink})`;
|
|
if (store) md += ` | ${store}`;
|
|
if (provider) md += ` | ${provider}`;
|
|
md += '\n\n';
|
|
if (created) md += `**Created:** ${created} \n`;
|
|
md += `**Status:** ${pStatus} \n`;
|
|
if (desc) md += `**Description:** ${desc}\n`;
|
|
md += '\n';
|
|
|
|
const lineItems = extractLineItems(proposal);
|
|
if (lineItems.length > 0) {
|
|
md += '### Materials / Line Items\n\n';
|
|
md += '| Part | Qty | Unit Price | Total |\n';
|
|
md += '| --- | ---: | ---: | ---: |\n';
|
|
for (const it of lineItems) {
|
|
md += `| ${_lineItemPart(it)} | ${_lineItemQty(it)} | ${_lineItemUnitPrice(it)} | ${_lineItemTotal(it)} |\n`;
|
|
}
|
|
md += '\n';
|
|
}
|
|
|
|
const categories = _filterCategories(proposal.AmountCategories || []);
|
|
if (categories.length > 0 || total > 0) {
|
|
md += '### Summary\n\n';
|
|
md += '| Category | Amount |\n';
|
|
md += '| --- | ---: |\n';
|
|
for (const cat of categories) {
|
|
md += `| ${cat.Name || 'Category'} | ${_formatMoney(cat.TotalCost)} |\n`;
|
|
}
|
|
if (total > 0) {
|
|
md += `| **Total** | **${_formatMoney(total)}** |\n`;
|
|
}
|
|
md += '\n';
|
|
}
|
|
|
|
const toReject = context.proposalsToReject || [];
|
|
if (toReject.length > 0) {
|
|
const list = toReject.map((p) => `#${p.number || p.id} (${_formatMoney(p.amount)})`).join(', ');
|
|
md += `> **Note:** Approving this proposal will reject prior proposal(s): ${list}\n`;
|
|
}
|
|
|
|
return md.trim();
|
|
}
|
|
|
|
function _buildCategoryColumnSet(categories, total) {
|
|
const rows = [];
|
|
const header = {
|
|
type: 'ColumnSet',
|
|
columns: [
|
|
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: 'Category', weight: 'bolder', size: 'small' }] },
|
|
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: 'Amount', weight: 'bolder', size: 'small', horizontalAlignment: 'right' }] },
|
|
],
|
|
};
|
|
rows.push(header);
|
|
|
|
for (const cat of categories) {
|
|
rows.push({
|
|
type: 'ColumnSet',
|
|
spacing: 'none',
|
|
columns: [
|
|
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: cat.Name || 'Category', size: 'small', wrap: true }] },
|
|
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: _formatMoney(cat.TotalCost), size: 'small', horizontalAlignment: 'right' }] },
|
|
],
|
|
});
|
|
}
|
|
|
|
if (total > 0) {
|
|
rows.push({
|
|
type: 'ColumnSet',
|
|
separator: true,
|
|
spacing: 'small',
|
|
columns: [
|
|
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: 'Total', weight: 'bolder', size: 'small' }] },
|
|
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: _formatMoney(total), weight: 'bolder', size: 'small', horizontalAlignment: 'right' }] },
|
|
],
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function _normalizeProposalsToReject(raw = []) {
|
|
return raw.map((p) => ({
|
|
id: _proposalRefId(p),
|
|
number: p.Number || p.ProposalNumber || p.ID || p.Id,
|
|
amount: Number(p.Amount ?? p.Total ?? p.TotalAmount ?? 0),
|
|
})).filter((p) => p.id);
|
|
}
|
|
|
|
/**
|
|
* Pick the pending proposal from associated list.
|
|
* Prefers note-parsed proposal #, else newest non-approved by CreatedDate.
|
|
*/
|
|
export function selectPendingProposal(associated = [], parsedProposalNumber = null) {
|
|
if (!associated.length) return null;
|
|
|
|
if (parsedProposalNumber) {
|
|
const match = associated.find((p) => _proposalRefNumber(p) === String(parsedProposalNumber));
|
|
if (match) return match;
|
|
}
|
|
|
|
const pending = associated.filter((p) => !_isApproved(p));
|
|
const pool = pending.length > 0 ? pending : associated;
|
|
|
|
const sorted = [...pool].sort((a, b) => {
|
|
const da = new Date(a.CreatedDate || a.CreatedDate_dto || 0).getTime();
|
|
const db = new Date(b.CreatedDate || b.CreatedDate_dto || 0).getTime();
|
|
return db - da;
|
|
});
|
|
|
|
return sorted[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Build a v1.3 Adaptive Card for proposal approval.
|
|
*/
|
|
export function buildApprovalAdaptiveCard(wo, proposals = [], currentNte = 0, options = {}) {
|
|
const { proposalsToReject = [] } = options;
|
|
const woNum = wo?.Number || wo?.Id || '???';
|
|
const woLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${wo?.Id || woNum}`;
|
|
const store = wo?.LocationStoreId ? `Store ${wo.LocationStoreId}` : (wo?.LocationName || '');
|
|
const status = `${wo?.Status?.Primary || 'IN PROGRESS'} | ${wo?.Status?.Extended || 'WAITING FOR APPROVAL'}`;
|
|
|
|
const proposal = proposals?.length > 0 ? proposals[0] : null;
|
|
|
|
let proposalAmount = 0;
|
|
if (proposal) {
|
|
proposalAmount = Number(
|
|
proposal.Amount ?? proposal.Total ?? proposal.TotalAmount ?? proposal.Cost ?? proposal.Value ?? proposal.Nte ?? 0
|
|
);
|
|
}
|
|
|
|
const items = extractLineItems(proposal);
|
|
if (!proposalAmount && items.length > 0) {
|
|
proposalAmount = items.reduce((sum, it) => sum + Number(it.Amount || it.Cost || it.Total || it.Value || 0), 0);
|
|
}
|
|
|
|
const suggestedNte = (Number(currentNte) || 0) + proposalAmount;
|
|
|
|
const body = [
|
|
{
|
|
type: 'TextBlock',
|
|
text: 'Proposal Approval Required',
|
|
size: 'medium',
|
|
weight: 'bolder',
|
|
},
|
|
{
|
|
type: 'TextBlock',
|
|
text: `[WO-${woNum}](${woLink}) • ${store}`,
|
|
isSubtle: true,
|
|
wrap: true,
|
|
},
|
|
{
|
|
type: 'FactSet',
|
|
facts: [
|
|
{ title: 'Current Status', value: status },
|
|
{ title: 'Current NTE', value: currentNte ? _formatMoney(currentNte) : 'N/A' },
|
|
],
|
|
},
|
|
];
|
|
|
|
if (proposalsToReject.length > 0) {
|
|
const list = proposalsToReject.map((p) => `#${p.number || p.id} (${_formatMoney(p.amount)})`).join(', ');
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: `Approving will reject prior proposal(s): ${list}`,
|
|
color: 'warning',
|
|
weight: 'bolder',
|
|
wrap: true,
|
|
spacing: 'medium',
|
|
});
|
|
}
|
|
|
|
if (proposal) {
|
|
const pNum = _proposalDisplayNumber(proposal);
|
|
const proposalDetailsUrl = _proposalDetailsUrl(proposal);
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: `Proposal #${pNum}`,
|
|
weight: 'bolder',
|
|
spacing: 'medium',
|
|
});
|
|
|
|
if (proposalDetailsUrl) {
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: `[View full proposal details in ServiceChannel](${proposalDetailsUrl})`,
|
|
size: 'small',
|
|
wrap: true,
|
|
spacing: 'small',
|
|
});
|
|
}
|
|
|
|
if (proposalAmount > 0) {
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: `Amount to add to NTE: **${_formatMoney(proposalAmount)}**`,
|
|
weight: 'bolder',
|
|
color: 'attention',
|
|
});
|
|
}
|
|
|
|
const pDesc = (proposal.Description || proposal.Description2 || proposal.Comments || '').toString().substring(0, 200);
|
|
if (pDesc) {
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: pDesc + (pDesc.length >= 200 ? '…' : ''),
|
|
size: 'small',
|
|
wrap: true,
|
|
isSubtle: true,
|
|
});
|
|
}
|
|
|
|
const categories = _filterCategories(proposal.AmountCategories || []);
|
|
if (categories.length > 0) {
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: 'Proposal Charges',
|
|
weight: 'bolder',
|
|
spacing: 'medium',
|
|
});
|
|
body.push(..._buildCategoryColumnSet(categories, proposalAmount));
|
|
} else if (proposalAmount > 0) {
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: `Total Proposal Amount: ${_formatMoney(proposalAmount)} (detailed categories not available)`,
|
|
size: 'small',
|
|
isSubtle: true,
|
|
wrap: true,
|
|
});
|
|
}
|
|
} else {
|
|
body.push({
|
|
type: 'TextBlock',
|
|
text: 'No specific proposal details found. You can still adjust the NTE manually below.',
|
|
wrap: true,
|
|
isSubtle: true,
|
|
});
|
|
}
|
|
|
|
body.push(
|
|
{
|
|
type: 'TextBlock',
|
|
text: 'New NTE Amount (current NTE + proposal amount + any additional work)',
|
|
weight: 'bolder',
|
|
spacing: 'medium',
|
|
},
|
|
{
|
|
type: 'Input.Number',
|
|
id: 'newNte',
|
|
value: suggestedNte || currentNte || 0,
|
|
placeholder: 'Enter final approved NTE',
|
|
min: 0,
|
|
},
|
|
{
|
|
type: 'TextBlock',
|
|
text: 'Optional comment / additional instructions (will be recorded with the approval)',
|
|
spacing: 'small',
|
|
},
|
|
{
|
|
type: 'Input.Text',
|
|
id: 'comment',
|
|
placeholder: 'e.g. Approved as quoted + $75 for expedited parts',
|
|
isMultiline: true,
|
|
}
|
|
);
|
|
|
|
return {
|
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
type: 'AdaptiveCard',
|
|
version: '1.3',
|
|
body,
|
|
actions: [
|
|
{
|
|
type: 'Action.Submit',
|
|
title: 'Approve Proposal',
|
|
data: {
|
|
action: 'approveNte',
|
|
workOrderId: wo?.Id || woNum,
|
|
proposalId: proposal?.Id || proposal?.ID || null,
|
|
proposalNumber: proposal?.Number || proposal?.ProposalNumber || null,
|
|
suggestedNte,
|
|
currentNte: Number(currentNte) || 0,
|
|
proposalAmount,
|
|
proposalsToRejectJson: JSON.stringify(proposalsToReject),
|
|
},
|
|
},
|
|
{
|
|
type: 'Action.Submit',
|
|
title: 'Cancel',
|
|
associatedInputs: 'none',
|
|
data: {
|
|
action: 'dismissApprovalCard',
|
|
workOrderId: wo?.Id || woNum,
|
|
proposalId: proposal?.Id || proposal?.ID || null,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Shared flow: fetch proposal data, post markdown summary + approval card.
|
|
*/
|
|
export async function postApprovalPackage(webexClient, roomId, woObj, noteData = null, { skipDedup = false, db = null } = {}) {
|
|
if (!roomId || !woObj) return;
|
|
|
|
const woId = woObj.Id;
|
|
const now = Date.now();
|
|
|
|
if (!skipDedup) {
|
|
_pruneDedupCache(now);
|
|
const dedupKeys = _dedupKeys(woId, noteData);
|
|
for (const k of dedupKeys) {
|
|
const lastPostedAt = _recentApprovalCards.get(k);
|
|
if (lastPostedAt && (now - lastPostedAt) < CARD_DEDUP_TTL_MS) {
|
|
logger('approval', `Skipping duplicate approval card for WO ${woId} (posted ${Math.round((now - lastPostedAt) / 1000)}s ago, key=${k})`);
|
|
return;
|
|
}
|
|
}
|
|
for (const k of dedupKeys) _recentApprovalCards.set(k, now);
|
|
}
|
|
|
|
const dedupKeys = _dedupKeys(woId, noteData);
|
|
|
|
try {
|
|
const parsedProposalNumber = _extractProposalNumber(noteData);
|
|
|
|
const [associated, proposalsToRejectRaw, woDetails] = await Promise.all([
|
|
getProposalsAssociatedWithWorkOrder(woId),
|
|
getProposalsToReject(woId),
|
|
getWorkOrderForNte(woId),
|
|
]);
|
|
|
|
logger('approval', `Found ${associated.length} associated proposals for WO ${woId}; ${proposalsToRejectRaw.length} to reject`);
|
|
|
|
let selectedProposal = null;
|
|
let proposalIdForLog = null;
|
|
|
|
if (associated.length > 0) {
|
|
const target = selectPendingProposal(associated, parsedProposalNumber);
|
|
const pid = target ? (target.ID || target.Id) : null;
|
|
if (pid) {
|
|
selectedProposal = await getProposalByIdOdata(pid);
|
|
proposalIdForLog = pid;
|
|
}
|
|
} else if (parsedProposalNumber) {
|
|
logger('approval', `No associated proposals for WO ${woId}; trying OData by proposal #${parsedProposalNumber}`);
|
|
selectedProposal = await getProposalByNumberOdata(parsedProposalNumber);
|
|
if (selectedProposal) {
|
|
proposalIdForLog = selectedProposal.Id || selectedProposal.ID || parsedProposalNumber;
|
|
}
|
|
}
|
|
|
|
const proposalsToReject = _normalizeProposalsToReject(proposalsToRejectRaw)
|
|
.filter((p) => !proposalIdForLog || String(p.id) !== String(proposalIdForLog));
|
|
const currentNte = woDetails?.Nte ?? woObj?.Nte ?? 0;
|
|
const woForDisplay = { ...woObj, ...woDetails };
|
|
|
|
const sender = webexClient && typeof webexClient.sendAdaptiveCard === 'function'
|
|
? webexClient
|
|
: webexService;
|
|
|
|
const markdown = formatProposalMarkdown(woForDisplay, selectedProposal, { proposalsToReject });
|
|
await sender.sendMarkdown(roomId, markdown);
|
|
|
|
const card = buildApprovalAdaptiveCard(
|
|
woForDisplay,
|
|
selectedProposal ? [selectedProposal] : [],
|
|
currentNte,
|
|
{ proposalsToReject }
|
|
);
|
|
|
|
const proposalAmountForFallback = selectedProposal
|
|
? Number(selectedProposal.Amount || selectedProposal.Total || selectedProposal.TotalAmount || 0)
|
|
: 0;
|
|
|
|
const fallback = `Approval card for WO-${woObj.Number || woId}. Proposal amount: ${_formatMoney(proposalAmountForFallback)}. Suggested NTE: ${_formatMoney((Number(currentNte) || 0) + proposalAmountForFallback)}.`;
|
|
|
|
if (db) {
|
|
const existing = await getPendingApprovalCard(db, woId);
|
|
if (existing?.messageId) {
|
|
await deleteApprovalCardMessage(existing.messageId);
|
|
}
|
|
}
|
|
|
|
const cardMsg = await sender.sendAdaptiveCard(roomId, card, fallback);
|
|
if (db && cardMsg?.id) {
|
|
await savePendingApprovalCard(db, {
|
|
workOrderId: woId,
|
|
roomId,
|
|
messageId: cardMsg.id,
|
|
proposalId: proposalIdForLog,
|
|
});
|
|
}
|
|
logger('approval', `Posted approval package for WO-${woObj.Number || woId} in room ${roomId} (proposalId=${proposalIdForLog || 'n/a'}, reject=${proposalsToReject.length}, messageId=${cardMsg?.id || 'n/a'})`);
|
|
} catch (err) {
|
|
if (!skipDedup) {
|
|
for (const k of dedupKeys) _recentApprovalCards.delete(k);
|
|
}
|
|
logger('approval', `Failed to post approval package for WO ${woId}: ${err.message}`, 'error');
|
|
try {
|
|
const md = `**Action Required: Proposal Approval for WO-${woObj?.Number || woId}**\n\nStatus: ${woObj?.Status?.Primary} | ${woObj?.Status?.Extended}\n\nPlease review proposals in ServiceChannel and update NTE manually.`;
|
|
const sender = webexClient?.sendMarkdown ? webexClient : webexService;
|
|
await sender.sendMarkdown(roomId, md);
|
|
} catch (_) {}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function fetchAndPostApprovalCardIfNeeded(webexClient, roomId, woObj, noteData = null, { db = null } = {}) {
|
|
return postApprovalPackage(webexClient, roomId, woObj, noteData, { db });
|
|
}
|
|
|
|
function _parseProposalsToRejectFromSubmit(inputs) {
|
|
if (inputs.proposalsToRejectJson) {
|
|
try {
|
|
const parsed = JSON.parse(inputs.proposalsToRejectJson);
|
|
if (Array.isArray(parsed)) return parsed;
|
|
} catch (_) {}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function _isAlreadyRejectedError(msg) {
|
|
const m = String(msg).toLowerCase();
|
|
return m.includes('already rejected') || m.includes('was already rejected');
|
|
}
|
|
|
|
function _isProposalResolved(noteData, woObj) {
|
|
const note = String(noteData || '');
|
|
const ext = (woObj?.Status?.Extended || '').toUpperCase();
|
|
if (/Proposal\s*#?\s*(\d+)\s+has been approved/i.test(note)) return true;
|
|
if (/Proposal\(s\)\s*#\s*(\d+)\s+has been rejected/i.test(note)) return true;
|
|
if (ext.includes('PROPOSAL APPROVED')) return true;
|
|
return false;
|
|
}
|
|
|
|
function getPendingApprovalCard(db, workOrderId) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(
|
|
'SELECT workOrderId, roomId, messageId, proposalId, postedAt FROM pending_approval_cards WHERE workOrderId = ?',
|
|
[workOrderId],
|
|
(err, row) => (err ? reject(err) : resolve(row || null))
|
|
);
|
|
});
|
|
}
|
|
|
|
function savePendingApprovalCard(db, { workOrderId, roomId, messageId, proposalId }) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(
|
|
`INSERT OR REPLACE INTO pending_approval_cards (workOrderId, roomId, messageId, proposalId, postedAt)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
[workOrderId, roomId, messageId, proposalId ?? null, new Date().toISOString()],
|
|
(err) => (err ? reject(err) : resolve())
|
|
);
|
|
});
|
|
}
|
|
|
|
function clearPendingApprovalCard(db, workOrderId) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(
|
|
'DELETE FROM pending_approval_cards WHERE workOrderId = ?',
|
|
[workOrderId],
|
|
(err) => (err ? reject(err) : resolve())
|
|
);
|
|
});
|
|
}
|
|
|
|
async function deleteApprovalCardMessage(messageId) {
|
|
if (!messageId) return;
|
|
try {
|
|
const botClientMod = await import('../integrations/webex/botClient.js');
|
|
await botClientMod.default.deleteMessage(messageId);
|
|
logger('approval', `Deleted approval card message ${messageId}`);
|
|
} catch (err) {
|
|
logger('approval', `Could not delete approval card ${messageId}: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
|
|
/** Remove the adaptive card message after a successful approval or dismiss. */
|
|
async function _removeApprovalCard(bot, action) {
|
|
const messageId = action?.messageId;
|
|
if (!messageId) return;
|
|
|
|
if (typeof bot?.censor === 'function') {
|
|
try {
|
|
await bot.censor(messageId);
|
|
logger('approval:submit', `Removed approval card message ${messageId}`);
|
|
return;
|
|
} catch (err) {
|
|
logger('approval:submit', `bot.censor failed for ${messageId}: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
|
|
try {
|
|
const botClientMod = await import('../integrations/webex/botClient.js');
|
|
await botClientMod.default.deleteMessage(messageId);
|
|
logger('approval:submit', `Removed approval card message ${messageId} via API`);
|
|
} catch (err) {
|
|
logger('approval:submit', `Could not remove approval card ${messageId}: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove stored approval card when proposal was approved/rejected in ServiceChannel.
|
|
*/
|
|
export async function removeApprovalCardIfResolved({ db, workOrderId, woObj, noteData }) {
|
|
if (!db || !workOrderId) return;
|
|
if (!_isProposalResolved(noteData, woObj)) return;
|
|
|
|
try {
|
|
const row = await getPendingApprovalCard(db, workOrderId);
|
|
if (!row?.messageId) return;
|
|
|
|
await deleteApprovalCardMessage(row.messageId);
|
|
await clearPendingApprovalCard(db, workOrderId);
|
|
logger('approval', `Removed stale approval card for WO ${workOrderId} (resolved in ServiceChannel)`);
|
|
} catch (err) {
|
|
logger('approval', `Failed to remove resolved approval card for WO ${workOrderId}: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
|
|
async function _rejectSupersededProposals(woId, proposalsToReject, approver, approverEmail, newProposalNumber) {
|
|
if (!proposalsToReject.length) return { rejected: [], failed: [] };
|
|
|
|
const reasonCodeId = await resolveRejectReasonCodeId();
|
|
const rejected = [];
|
|
const failed = [];
|
|
|
|
for (const p of proposalsToReject) {
|
|
const pid = p.id;
|
|
if (!pid) continue;
|
|
|
|
const rejectBody = {
|
|
Comments: `Superseded by proposal #${newProposalNumber || 'new'} — rejected via ServChan by ${approver}`,
|
|
ProviderEmail: '',
|
|
UserEmail: '',
|
|
RejectReasonCodeId: reasonCodeId,
|
|
ActionSource: 'Standard',
|
|
ReasonString: `Superseded by revised proposal — rejected via ServChan by ${approver}`,
|
|
PinNote: false,
|
|
};
|
|
|
|
try {
|
|
await rejectProposal(pid, rejectBody);
|
|
rejected.push(p);
|
|
logger('approval:submit', `Rejected superseded proposal ${pid} (#${p.number}) for WO ${woId}`);
|
|
} catch (err) {
|
|
const msg = err.message || err.toString();
|
|
const scCode = err.response?.data?.ErrorCode;
|
|
if (_isAlreadyRejectedError(msg)) {
|
|
logger('approval:submit', `Proposal ${pid} already rejected — skipping`, 'warn');
|
|
rejected.push(p);
|
|
} else {
|
|
failed.push({ ...p, error: msg, scCode });
|
|
logger('approval:submit', `Failed to reject proposal ${pid}: ${msg}`, 'warn');
|
|
}
|
|
}
|
|
}
|
|
|
|
return { rejected, failed };
|
|
}
|
|
|
|
export async function handleApprovalSubmit(bot, trigger, { db = null } = {}) {
|
|
const action = trigger?.attachmentAction;
|
|
if (!action || !action.inputs) return;
|
|
|
|
const inputs = action.inputs;
|
|
|
|
if (inputs.action === 'dismissApprovalCard') {
|
|
const woId = inputs.workOrderId;
|
|
await _removeApprovalCard(bot, action);
|
|
if (db && woId) {
|
|
try {
|
|
await clearPendingApprovalCard(db, woId);
|
|
} catch (err) {
|
|
logger('approval:submit', `Could not clear pending card row for WO ${woId}: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
try {
|
|
await bot.say('Approval card dismissed. Approve in ServiceChannel when ready.');
|
|
} catch (_) {}
|
|
logger('approval:submit', `Dismissed approval card for WO ${woId || 'unknown'}`);
|
|
return;
|
|
}
|
|
|
|
if (inputs.action !== 'approveNte') return;
|
|
|
|
const woId = inputs.workOrderId;
|
|
const proposalId = inputs.proposalId;
|
|
const newProposalNumber = inputs.proposalNumber || proposalId;
|
|
const newNte = inputs.newNte;
|
|
const comment = inputs.comment || '';
|
|
|
|
if (!woId || newNte === undefined) {
|
|
try { await bot.say('Missing work order or NTE amount in approval submission.'); } catch (_) {}
|
|
return;
|
|
}
|
|
|
|
const numericNte = Number(newNte);
|
|
if (!Number.isFinite(numericNte) || numericNte < 0) {
|
|
try { await bot.say('Please enter a valid non-negative NTE amount.'); } catch (_) {}
|
|
return;
|
|
}
|
|
|
|
const suggestedNte = Number(inputs.suggestedNte);
|
|
const nteWasOverridden = Number.isFinite(suggestedNte) && Math.abs(numericNte - suggestedNte) > 0.005;
|
|
|
|
let approver = 'Webex user';
|
|
let approverEmail = '';
|
|
try {
|
|
const botClientMod = await import('../integrations/webex/botClient.js');
|
|
const details = await botClientMod.default.getPersonDetails(action.personId);
|
|
approverEmail = details?.emails?.[0] || '';
|
|
approver = details?.displayName || approverEmail || action.personId || 'Webex user';
|
|
} catch (e) {
|
|
logger('approval:submit', `Could not resolve person ${action.personId}: ${e.message}`, 'warn');
|
|
}
|
|
|
|
let proposalsToReject = _parseProposalsToRejectFromSubmit(inputs);
|
|
let approveSucceeded = false;
|
|
let nteSucceeded = false;
|
|
const errorMessages = [];
|
|
let rejectedList = [];
|
|
|
|
// Step 0 — Reject superseded proposals (never reject the proposal we are about to approve)
|
|
if (proposalsToReject.length === 0) {
|
|
const fresh = await getProposalsToReject(woId);
|
|
proposalsToReject = _normalizeProposalsToReject(fresh);
|
|
}
|
|
if (proposalId) {
|
|
proposalsToReject = proposalsToReject.filter((p) => String(p.id) !== String(proposalId));
|
|
}
|
|
|
|
let rejectFailures = [];
|
|
if (proposalsToReject.length > 0) {
|
|
const { rejected, failed } = await _rejectSupersededProposals(
|
|
woId, proposalsToReject, approver, approverEmail, newProposalNumber
|
|
);
|
|
rejectedList = rejected;
|
|
rejectFailures = failed;
|
|
if (failed.length > 0) {
|
|
const failMsg = failed.map((f) => `#${f.number || f.id}: ${f.error}`).join('; ');
|
|
logger('approval:submit', `Pre-approve reject failed for WO ${woId} (will still attempt approve): ${failMsg}`, 'warn');
|
|
}
|
|
}
|
|
|
|
// Step 1 — Approve the proposal
|
|
try {
|
|
if (proposalId) {
|
|
const approveBody = {
|
|
Comments: `Approved by ${approver} via ServChan card${comment ? `: ${comment}` : ''}`,
|
|
ProviderEmail: '',
|
|
UserEmail: approverEmail,
|
|
RejectReasonCodeId: 0,
|
|
ActionSource: 'Standard',
|
|
ReasonString: `Approved by ${approver} via ServChan${comment ? `: ${comment}` : ''}`,
|
|
};
|
|
await approveProposal(proposalId, approveBody);
|
|
approveSucceeded = true;
|
|
logger('approval:submit', `Successfully approved proposal ${proposalId} for WO ${woId} by ${approver}`);
|
|
} else {
|
|
errorMessages.push('Missing proposalId — cannot approve via SC API');
|
|
logger('approval:submit', `Cannot approve WO ${woId}: no proposalId in submit payload`, 'error');
|
|
}
|
|
} catch (err) {
|
|
let msg = err.message || err.toString();
|
|
|
|
// Defensive retry: fetch fresh reject list and try once more
|
|
if (msg.toLowerCase().includes('reject') && proposalsToReject.length === 0) {
|
|
const fresh = _normalizeProposalsToReject(await getProposalsToReject(woId));
|
|
if (fresh.length > 0) {
|
|
const { rejected, failed } = await _rejectSupersededProposals(
|
|
woId, fresh, approver, approverEmail, newProposalNumber
|
|
);
|
|
rejectedList = rejected;
|
|
rejectFailures = failed;
|
|
try {
|
|
await approveProposal(proposalId, {
|
|
Comments: `Approved by ${approver} via ServChan card${comment ? `: ${comment}` : ''}`,
|
|
ProviderEmail: '',
|
|
UserEmail: approverEmail,
|
|
RejectReasonCodeId: 0,
|
|
ActionSource: 'Standard',
|
|
ReasonString: `Approved by ${approver} via ServChan${comment ? `: ${comment}` : ''}`,
|
|
});
|
|
approveSucceeded = true;
|
|
logger('approval:submit', `Approved proposal ${proposalId} after defensive reject retry for WO ${woId}`);
|
|
} catch (retryErr) {
|
|
msg = retryErr.message || retryErr.toString();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!approveSucceeded) {
|
|
errorMessages.push(`Proposal approval: ${msg}`);
|
|
logger('approval:submit', `Proposal approve failed for ${proposalId}: ${msg}`, 'error');
|
|
if (msg.includes('804') || msg.includes('no permissions')) {
|
|
try {
|
|
const latest = await getProposalByIdOdata(proposalId);
|
|
const st = latest?.Status?.Primary || latest?.Status;
|
|
if (st && String(st).toLowerCase() === 'rejected') {
|
|
errorMessages.push(
|
|
'The target proposal is already Rejected in ServiceChannel — it may have been rejected in the pre-approve step. Check SC for the correct pending proposal.'
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2 — Optional NTE override
|
|
if (approveSucceeded && nteWasOverridden) {
|
|
try {
|
|
await updateWorkOrderNte(woId, numericNte);
|
|
nteSucceeded = true;
|
|
logger('approval:submit', `NTE overridden to $${numericNte.toFixed(2)} for WO ${woId}`);
|
|
} catch (err) {
|
|
const msg = err.message || err.toString();
|
|
logger('approval:submit', `Direct NTE override rejected by SC for WO ${woId}: ${msg}`, 'warn');
|
|
errorMessages.push(`Could not force NTE to $${numericNte.toFixed(2)} — SC applied proposal amount automatically.`);
|
|
}
|
|
}
|
|
|
|
// Step 3 — Confirmation
|
|
if (approveSucceeded) {
|
|
const nteLine = nteWasOverridden
|
|
? (nteSucceeded
|
|
? `NTE overridden to **${_formatMoney(numericNte)}**.`
|
|
: `NTE stays at SC's auto value (${_formatMoney(suggestedNte)}) — direct override was rejected. Adjust manually in SC if needed.`)
|
|
: `NTE will be raised to **${_formatMoney(suggestedNte)}** by ServiceChannel automatically.`;
|
|
|
|
let rejectLine = '';
|
|
if (rejectedList.length > 0) {
|
|
const list = rejectedList.map((p) => `#${p.number || p.id}`).join(', ');
|
|
rejectLine = `Prior proposal(s) rejected: **${list}**.\n\n`;
|
|
} else if (rejectFailures.length > 0) {
|
|
const list = rejectFailures.map((p) => `#${p.number || p.id}`).join(', ');
|
|
rejectLine = `Note: Could not auto-reject prior proposal(s) ${list} — verify status in ServiceChannel.\n\n`;
|
|
}
|
|
|
|
const confirm =
|
|
`✅ **Proposal approved** by **${approver}** for WO **${woId}**.\n\n` +
|
|
rejectLine +
|
|
`${nteLine}` +
|
|
(comment ? `\n\nComment: ${comment}` : '') +
|
|
`\n\nAn audit note has been recorded on the work order in ServiceChannel.`;
|
|
|
|
await _removeApprovalCard(bot, action);
|
|
if (db && woId) {
|
|
try {
|
|
await clearPendingApprovalCard(db, woId);
|
|
} catch (err) {
|
|
logger('approval:submit', `Could not clear pending card row for WO ${woId}: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
await bot.say({ markdown: confirm });
|
|
logger('approval:submit',
|
|
`Success for WO ${woId} / proposal ${proposalId || 'n/a'}: rejected=${rejectedList.length}, ` +
|
|
`nteOverride=${nteWasOverridden ? (nteSucceeded ? 'applied' : 'rejected') : 'not-requested'} by ${approver}`
|
|
);
|
|
} else {
|
|
const isTokenError = errorMessages.some((m) => m.includes('token fetch failed'));
|
|
const confirm = isTokenError
|
|
? `❌ **ServiceChannel login failed** — ServChan could not authenticate to the SC API.\n\n` +
|
|
`This usually means \`SC_USERNAME\` / \`SC_PASSWORD\` in \`.env\` do not match the SC user, or the bot was not restarted after updating credentials.\n\n` +
|
|
`Details: ${errorMessages.join('; ')}\n\n` +
|
|
`Fix credentials and restart ServChan, then retry approval in ServiceChannel or re-post the card.`
|
|
: `❌ Failed to approve proposal for WO ${woId}: ${errorMessages.join('; ')}. Please approve manually in ServiceChannel.`;
|
|
await bot.say({ markdown: confirm });
|
|
logger('approval:submit', `Complete failure for WO ${woId} / proposal ${proposalId || 'n/a'}: ${errorMessages.join('; ')}`, 'error');
|
|
}
|
|
}
|
|
|
|
export default {
|
|
buildApprovalAdaptiveCard,
|
|
formatProposalMarkdown,
|
|
extractLineItems,
|
|
selectPendingProposal,
|
|
postApprovalPackage,
|
|
fetchAndPostApprovalCardIfNeeded,
|
|
removeApprovalCardIfResolved,
|
|
handleApprovalSubmit,
|
|
};
|