/** * 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 = 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: `Proposal total (new 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 (defaults to proposal total; adjust if needed)', weight: 'bolder', spacing: 'medium', }, { type: 'Input.Number', id: 'newNte', value: suggestedNte || 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(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.`) : `New NTE will be **${_formatMoney(suggestedNte)}** (proposal total) 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, };