servchan/src/services/approvalService.js
jmcqueen c2e98d105e Initial commit: ServChan Webex bot for ServiceChannel work orders.
ServiceChannel webhook processor, proposal approval cards, attachment auto-post,
CollabSupport commands, and Docker deployment configuration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 15:12:08 -04:00

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