Add invoice approval cards, /confirmed close-out fallback, and /addNote.

Replace /completed with /confirmed that tries SC CONFIRMED then falls back to SC notes and ServChan close-out records when status is locked. Post invoice approval cards on PDF attach, track close-outs for cleanup, and add WO-space /addNote.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-08-25 12:59:54 -04:00
parent bd02d51111
commit cba047cb4e
15 changed files with 953 additions and 125 deletions

View file

@ -39,10 +39,13 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport
# OPTISIGN_API_KEY=...
# --- Space cleanup (optional) ---
# Webex room for daily consolidated close-out digest and /completed ops notifications.
# Webex room for daily consolidated close-out digest and /confirmed ops notifications.
# COMPLETED_OPERATIONS_ROOM_ID=Y2lzY29zcGFyazovL3VzL1JPT00vNjdmZmYxZTAtZmM3Ny0xMWYwLWE2MzUtZGY0ZmQ4NWUwMGMz
# Days after any non-terminal COMPLETED status before including in the daily digest.
# Auto-remove still applies only to COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE.
# Only work orders for this service provider are included in cleanup (default: Pro-Motion).
# SPACE_CLEANUP_PROVIDER_NAME=Pro-Motion Technology Group, LLC
# SPACE_CLEANUP_PROVIDER_ID=2000002215
# SPACE_CLEANUP_REMINDER_DAYS=60
# Cron for daily consolidated digest (default 14:00 UTC). Digest only — no auto-delete.
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *

View file

@ -85,12 +85,30 @@ db.serialize(() => {
postedAt TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS pending_invoice_approval_cards (
workOrderId INTEGER PRIMARY KEY,
roomId TEXT NOT NULL,
messageId TEXT NOT NULL,
invoiceId INTEGER,
postedAt TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS space_cleanup_digest (
id INTEGER PRIMARY KEY CHECK (id = 1),
lastPostedAt TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS wo_closeouts (
workOrderId INTEGER PRIMARY KEY,
confirmedAt TEXT NOT NULL,
confirmedBy TEXT NOT NULL,
scStatusUpdated INTEGER NOT NULL DEFAULT 0,
noteText TEXT
)
`);
console.log(`[DB] Connected to ${DB_PATH}`);
});

View file

@ -16,8 +16,10 @@ import { handleWoAttachments } from '../commands/woAttachments.js';
import { handleWoHistory } from '../commands/woHistory.js';
import { handleAvStatus } from '../commands/avStatus.js';
import { handleWoApprove } from '../commands/woApprove.js';
import { handleWoCompleted } from '../commands/woCompleted.js';
import { handleWoConfirmed } from '../commands/woConfirmed.js';
import { handleWoAddNote } from '../commands/woAddNote.js';
import approvalService from '../services/approvalService.js';
import invoiceApprovalService from '../services/invoiceApprovalService.js';
import { installMercuryGuard } from './mercuryGuard.js';
import db from '../db/mappings.js';
@ -64,6 +66,15 @@ export function initializeBot({ webexConfig }) {
// NEW: Handle Adaptive Card submissions (e.g. proposal approvals)
Framework.on('attachmentAction', async (bot, trigger) => {
try {
const actionType = trigger?.attachmentAction?.inputs?.action;
if (
actionType === 'approveInvoice' ||
actionType === 'rejectInvoice' ||
actionType === 'dismissInvoiceCard'
) {
await invoiceApprovalService.handleInvoiceApprovalSubmit(bot, trigger, { db });
return;
}
await approvalService.handleApprovalSubmit(bot, trigger, { db });
} catch (e) {
logger('bot:approvalAction', `Handler error: ${e.message}`, 'error');
@ -122,8 +133,11 @@ export function initializeBot({ webexConfig }) {
case 'requestapproval':
await handleWoApprove(bot, trigger);
break;
case 'completed':
await handleWoCompleted(bot, trigger);
case 'confirmed':
await handleWoConfirmed(bot, trigger);
break;
case 'addnote':
await handleWoAddNote(bot, trigger);
break;
default:
await handleUnknown(bot, trigger);

View file

@ -10,7 +10,8 @@ export async function handleHelp(bot, trigger) {
text += `- **/woAttachments** — download attachments for the workorder in this space\n`;
text += `- **/avStatus** — AV device status for the store linked to this space\n`;
text += `- **/woApprove** — (re)post proposal approval card for current WO (when WAITING FOR APPROVAL)\n`;
text += `- **/completed** — confirm WO resolution verified and notify ops space for close-out\n`;
text += `- **/confirmed** — confirm WO resolution (SC CONFIRMED if allowed, else note + ops)\n`;
text += `- **/addNote** — add a note to this work order in ServiceChannel (include your text after the command)\n`;
} else {
text += `- **/woSummary <WO-number>** — status of any work order\n`;
text += `- **/woHistory <store-number>** — History of AV issues.\n`;

37
src/commands/woAddNote.js Normal file
View file

@ -0,0 +1,37 @@
// src/commands/woAddNote.js
import botClient from '../integrations/webex/botClient.js';
import { addWorkOrderNote } from '../integrations/serviceChannel/client.js';
import db from '../db/mappings.js';
import { logger } from '../utils/logger.js';
import { resolveDisplayName, resolveWoRoomContext } from '../utils/woRoomContext.js';
export async function handleWoAddNote(bot, trigger) {
logger('wo:addNote', 'HANDLER ENTERED');
const roomId = trigger.message?.roomId;
const isGroup = trigger.message?.roomType === 'group';
const noteText = (trigger.args || []).join(' ').trim();
if (!noteText) {
await bot.say('Usage: `/addNote your note text here` (WO space only).');
return;
}
const ctx = await resolveWoRoomContext({ db, botClient, roomId, isGroup });
if (!ctx.ok) {
await bot.say(ctx.error);
return;
}
const displayName = await resolveDisplayName(botClient, trigger.personId);
const scNote = `${displayName}: ${noteText}`;
try {
await addWorkOrderNote(ctx.workOrderId, scNote);
logger('wo:addNote', `Posted note to SC WO ${ctx.workOrderId} by ${displayName}`);
await bot.say(`Note added to the work order in ServiceChannel.`);
} catch (err) {
logger('wo:addNote', `Failed to post SC note for WO ${ctx.workOrderId}: ${err.message}`, 'error');
await bot.say(`Could not add the note in ServiceChannel: ${err.message}`);
}
}

View file

@ -1,113 +0,0 @@
// src/commands/woCompleted.js
import botClient from '../integrations/webex/botClient.js';
import { addWorkOrderNote } from '../integrations/serviceChannel/client.js';
import db from '../db/mappings.js';
import { logger } from '../utils/logger.js';
import {
getCompletedOperationsRoomId,
parseServChanRoomTitle,
} from '../utils/servchanRoomTitle.js';
function lookupWorkOrderIdByRoom(roomId) {
return new Promise((resolve, reject) => {
db.get(
'SELECT workOrderId FROM mappings WHERE roomId = ?',
[roomId],
(err, row) => (err ? reject(err) : resolve(row?.workOrderId ?? null))
);
});
}
function buildConfirmationText(displayName) {
return `${displayName} has confirmed this WO resolution verified and approves closing it out.`;
}
export async function handleWoCompleted(bot, trigger) {
logger('wo:completed', 'HANDLER ENTERED');
const roomId = trigger.message?.roomId;
const isGroup = trigger.message?.roomType === 'group';
if (!isGroup || !roomId) {
await bot.say('This command can only be used in a ServChan work order space.');
return;
}
const mappedWorkOrderId = await lookupWorkOrderIdByRoom(roomId);
if (!mappedWorkOrderId) {
await bot.say('This command can only be used in a ServChan work order space.');
return;
}
let room;
try {
room = await botClient.getRoom(roomId);
} catch (err) {
logger('wo:completed', `Failed to fetch room ${roomId}: ${err.message}`, 'warn');
await bot.say('Could not load this Webex space. Please try again.');
return;
}
const parsed = parseServChanRoomTitle(room?.title);
if (!parsed) {
await bot.say('This command can only be used in a ServChan work order space.');
return;
}
const opsRoomId = getCompletedOperationsRoomId();
if (!opsRoomId) {
logger('wo:completed', 'COMPLETED_OPERATIONS_ROOM_ID not configured', 'warn');
await bot.say('The close-out ops space is not configured on this bot (`COMPLETED_OPERATIONS_ROOM_ID`).');
return;
}
const personId = trigger.personId;
const person = personId
? await botClient.getPersonDetails(personId)
: { displayName: 'Unknown User' };
const displayName = person.displayName || 'Unknown User';
const confirmationText = buildConfirmationText(displayName);
await bot.say({ markdown: `**${confirmationText}**` });
let scNotePosted = false;
try {
await addWorkOrderNote(mappedWorkOrderId, confirmationText);
scNotePosted = true;
logger(
'wo:completed',
`Posted close-out note to SC WO ${mappedWorkOrderId} by ${displayName}`
);
} catch (err) {
logger('wo:completed', `Failed to post SC note for WO ${mappedWorkOrderId}: ${err.message}`, 'error');
}
const opsMessage = `${parsed.headerLine}\n${confirmationText}`;
try {
await botClient.sendMarkdown(opsRoomId, opsMessage);
logger(
'wo:completed',
`Posted close-out approval for WO-${parsed.woNumber} to ops room ${opsRoomId} by ${displayName}`
);
if (scNotePosted) {
await bot.say('Close-out approval posted to ServiceChannel and the ServChan ops space.');
} else {
await bot.say(
'Close-out approval posted to the ServChan ops space, but adding the ServiceChannel note failed — please add it manually in SC.'
);
}
} catch (err) {
logger('wo:completed', `Failed to post to ops room: ${err.message}`, 'error');
if (scNotePosted) {
await bot.say(
`Your confirmation was posted here and noted on the work order in ServiceChannel, but notifying the ops space failed: ${err.message}`
);
} else {
await bot.say(
`Your confirmation was posted here, but the ServiceChannel note and ops space notification failed: ${err.message}`
);
}
}
}

159
src/commands/woConfirmed.js Normal file
View file

@ -0,0 +1,159 @@
// src/commands/woConfirmed.js
import botClient from '../integrations/webex/botClient.js';
import {
addWorkOrderNote,
getWorkOrderStatus,
updateWorkOrderStatus,
} from '../integrations/serviceChannel/client.js';
import db from '../db/mappings.js';
import { logger } from '../utils/logger.js';
import { getCompletedOperationsRoomId } from '../utils/servchanRoomTitle.js';
import { resolveDisplayName, resolveWoRoomContext } from '../utils/woRoomContext.js';
import { getWoCloseout, saveWoCloseout } from '../services/woCloseoutService.js';
function normalizeExtended(value) {
return (value || '').trim().toUpperCase();
}
function isTerminalExtended(extendedRaw) {
const extended = normalizeExtended(extendedRaw);
if (extended === 'CONFIRMED' || extended === 'CANCELLED') return true;
return extended === 'NO CHARGE' || extended.includes('NO CHARGE');
}
function buildConfirmationText(displayName) {
return `${displayName} has confirmed this WO resolution verified and approves closing it out.`;
}
function isScStatusLockedError(err) {
const msg = String(err?.message || err).toLowerCase();
return msg.includes('921') || msg.includes('invoiced') || msg.includes('status');
}
export async function handleWoConfirmed(bot, trigger) {
logger('wo:confirmed', 'HANDLER ENTERED');
const roomId = trigger.message?.roomId;
const isGroup = trigger.message?.roomType === 'group';
const ctx = await resolveWoRoomContext({ db, botClient, roomId, isGroup });
if (!ctx.ok) {
await bot.say(ctx.error);
return;
}
const { workOrderId: mappedWorkOrderId, parsed } = ctx;
const opsRoomId = getCompletedOperationsRoomId();
if (!opsRoomId) {
logger('wo:confirmed', 'COMPLETED_OPERATIONS_ROOM_ID not configured', 'warn');
await bot.say('The close-out ops space is not configured on this bot (`COMPLETED_OPERATIONS_ROOM_ID`).');
return;
}
const existingCloseout = await getWoCloseout(db, mappedWorkOrderId);
if (existingCloseout) {
await bot.say(
`Close-out was already recorded for this work order on ${new Date(existingCloseout.confirmedAt).toLocaleDateString('en-US')} by **${existingCloseout.confirmedBy}**.`
);
return;
}
const woStatus = await getWorkOrderStatus(mappedWorkOrderId);
if (!woStatus) {
await bot.say('Could not load this work order from ServiceChannel. Please try again.');
return;
}
const primary = (woStatus.primaryStatus || '').trim().toUpperCase();
if (primary !== 'COMPLETED') {
await bot.say(
`This command applies only to **COMPLETED** work orders. Current status: **${woStatus.primaryStatus || 'unknown'}**${woStatus.extendedStatus ? ` / ${woStatus.extendedStatus}` : ''}.`
);
return;
}
if (isTerminalExtended(woStatus.extendedStatus)) {
await bot.say(
`This work order is already in a terminal status (**${woStatus.extendedStatus}**). No action needed.`
);
return;
}
const displayName = await resolveDisplayName(botClient, trigger.personId);
const confirmationText = buildConfirmationText(displayName);
const scNote = `${displayName} confirmed WO resolution verified and approves closing it out.`;
let scStatusUpdated = false;
try {
await updateWorkOrderStatus(mappedWorkOrderId, {
primary: 'COMPLETED',
extended: 'CONFIRMED',
note: scNote,
});
scStatusUpdated = true;
logger(
'wo:confirmed',
`Set WO ${mappedWorkOrderId} to COMPLETED/CONFIRMED by ${displayName}`
);
} catch (err) {
logger(
'wo:confirmed',
`SC status update failed for WO ${mappedWorkOrderId}: ${err.message} — falling back to note-only close-out`,
'warn'
);
try {
await addWorkOrderNote(mappedWorkOrderId, scNote);
logger(
'wo:confirmed',
`Posted close-out note to SC WO ${mappedWorkOrderId} by ${displayName} (status API blocked)`
);
} catch (noteErr) {
logger('wo:confirmed', `Failed to post SC note for WO ${mappedWorkOrderId}: ${noteErr.message}`, 'error');
const hint = isScStatusLockedError(err)
? ' ServiceChannel does not allow CONFIRMED after invoice on this work order.'
: '';
await bot.say(
`Could not set ServiceChannel status to CONFIRMED and adding a work order note also failed:${hint} ${noteErr.message}`
);
return;
}
}
try {
await saveWoCloseout(db, {
workOrderId: mappedWorkOrderId,
confirmedBy: displayName,
scStatusUpdated,
noteText: scNote,
});
} catch (err) {
logger('wo:confirmed', `Failed to save close-out record for WO ${mappedWorkOrderId}: ${err.message}`, 'error');
}
const opsMessage = `${parsed.headerLine}\n${confirmationText}`;
try {
await botClient.sendMarkdown(opsRoomId, opsMessage);
logger(
'wo:confirmed',
`Posted close-out confirmation for WO-${parsed.woNumber} to ops room ${opsRoomId} by ${displayName}`
);
} catch (err) {
logger('wo:confirmed', `Failed to post to ops room: ${err.message}`, 'error');
await bot.say(
scStatusUpdated
? `ServiceChannel status was updated to CONFIRMED, but notifying the ops space failed: ${err.message}`
: `Close-out was noted on the work order in ServiceChannel, but notifying the ops space failed: ${err.message}`
);
return;
}
if (!scStatusUpdated) {
await bot.say(
'ServiceChannel would not allow CONFIRMED on this work order — close-out noted on the WO, recorded in ServChan, and ops notified. The SC webhook should post the note here shortly.'
);
}
}

View file

@ -270,10 +270,21 @@ export async function getTicketsReadyForSpaceCleanup(daysAfterCompletion = 7) {
export async function getWorkOrderStatus(woId) {
try {
const response = await scAxios.get(`/workorders/${woId}`, {
params: { $select: 'Id,WorkorderNumber,Status,UpdatedDate,Description' }
params: {
$select: 'Id,WorkorderNumber,Status,UpdatedDate,Description,Provider,ProviderName,ProviderId,IsInvoiced,Invoice,ApprovalCode,Category',
},
});
const ticket = response.data;
const invoiceId = ticket.Invoice?.Id ?? null;
let invoiceTotal = ticket.Invoice?.InvoiceTotal ?? ticket.Invoice?.Total ?? null;
if (invoiceId && (invoiceTotal == null || Number(invoiceTotal) === 0)) {
const inv = await getInvoice(invoiceId);
if (inv) {
invoiceTotal = inv.InvoiceTotal ?? inv.Total ?? invoiceTotal;
}
}
return {
workOrderId: ticket.Id,
@ -281,13 +292,95 @@ export async function getWorkOrderStatus(woId) {
primaryStatus: ticket.Status?.Primary,
extendedStatus: ticket.Status?.Extended,
updatedDate: ticket.UpdatedDate,
description: ticket.Description?.substring(0, 100) || ''
description: ticket.Description?.substring(0, 100) || '',
providerName: ticket.Provider?.Name || ticket.ProviderName || '',
providerId: ticket.Provider?.Id ?? ticket.ProviderId ?? null,
isInvoiced: ticket.IsInvoiced === true,
invoiceId,
invoiceNumber: ticket.Invoice?.Number ?? null,
invoiceTotal: invoiceTotal != null ? Number(invoiceTotal) : null,
approvalCode: ticket.ApprovalCode ?? '',
category: ticket.Category ?? '',
};
} catch (err) {
console.error(`[SC-CLIENT] Error getting status for WO ${woId}:`, err.message);
return null;
}
}
/**
* Update work order primary/extended status.
* PUT /workorders/{woId}/status
*/
export async function updateWorkOrderStatus(woId, { primary, extended, note }) {
if (!woId || !primary) {
throw new Error('woId and primary status are required');
}
const body = {
Status: {
Primary: primary,
Extended: extended || '',
},
Note: String(note || '').trim(),
};
await scAxios.put(`/workorders/${woId}/status`, body);
logger(
'sc:updateWorkOrderStatus',
`WO ${woId} status → ${primary}${extended ? ` / ${extended}` : ''}`
);
}
/**
* Approve an invoice in ServiceChannel.
* PUT /invoices/{invoiceId}/approve
*/
export async function approveInvoice(invoiceId, { approvalCode = '', comments = '', category = '' } = {}) {
if (!invoiceId) {
throw new Error('invoiceId is required to approve');
}
const params = new URLSearchParams();
params.set('approvalCode', String(approvalCode ?? ''));
params.set('comments', String(comments ?? ''));
params.set('category', String(category ?? ''));
await scAxios.put(`/invoices/${invoiceId}/approve?${params.toString()}`);
logger('sc:approveInvoice', `Approved invoice ${invoiceId}`);
}
/**
* Reject an invoice in ServiceChannel.
* PUT /invoices/{invoiceId}/reject
*/
export async function rejectInvoice(invoiceId, { comments = '', isNotifyProvider = true } = {}) {
if (!invoiceId) {
throw new Error('invoiceId is required to reject');
}
const params = new URLSearchParams();
params.set('comments', String(comments ?? ''));
params.set('isNotifyProvider', String(isNotifyProvider));
await scAxios.put(`/invoices/${invoiceId}/reject?${params.toString()}`);
logger('sc:rejectInvoice', `Rejected invoice ${invoiceId}`);
}
/**
* Fetch invoice details by id.
* GET /invoices/{invoiceId}
*/
export async function getInvoice(invoiceId) {
if (!invoiceId) return null;
try {
const res = await fetchWithRetry(`/invoices/${invoiceId}`, { timeout: 30000 });
return res.data;
} catch (err) {
logger('sc:getInvoice', `Failed for invoice ${invoiceId}: ${err.message}`, 'warn');
return null;
}
}
export default scAxios;
// ──────────────────────────────────────────────

View file

@ -197,11 +197,14 @@ export function createApp({
: 'Destructive actions were performed against Webex and the mappings DB.'}
<br><br>
<strong>Rules:</strong>
Cleanup applies only to service provider <code>${esc(process.env.SPACE_CLEANUP_PROVIDER_NAME || 'Pro-Motion Technology Group, LLC')}</code>
(ID <code>${esc(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215')}</code>).
Auto-remove (prune 14d / delete 60d) for <code>COMPLETED / CONFIRMED</code>,
<code>COMPLETED / CANCELLED</code>, and <code>COMPLETED / NO CHARGE</code>.
All other <code>COMPLETED</code> variants are held until one of those terminal statuses;
a consolidated daily digest posts to the ops room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days.
Use <code>/completed</code> in a ServChan WO space to confirm resolution verified.
Invoice PDFs trigger an approval card in the WO room; use <code>/confirmed</code> after invoice
(sets SC CONFIRMED when allowed, otherwise records close-out via SC note + ServChan DB).
</div>
<h2>Summary</h2>
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>

View file

@ -5,6 +5,7 @@
*/
import { logger } from '../utils/logger.js';
import invoiceApprovalService from './invoiceApprovalService.js';
import {
listWorkOrderAttachments,
downloadAttachment,
@ -224,6 +225,18 @@ export async function postWorkOrderAttachments({
await dbMarkPosted(db, workOrderId, att.id);
}
posted++;
if (att.isInvoice) {
invoiceApprovalService.postInvoiceApprovalCard({
db,
webex,
roomId,
workOrderId,
woNumber: woNumber || workOrderId,
}).catch((err) => {
logger('attachments', `Invoice approval card failed for WO ${workOrderId}: ${err.message}`, 'warn');
});
}
}
} catch (err) {
logger('attachments', `Failed to post ${att.name} (${att.id}): ${err.message}`, 'warn');

View file

@ -0,0 +1,438 @@
/**
* src/services/invoiceApprovalService.js
*
* Invoice approval Adaptive Card flow when invoice PDFs are posted to WO rooms.
*/
import { logger } from '../utils/logger.js';
import {
getWorkOrderStatus,
getInvoice,
approveInvoice,
rejectInvoice,
} from '../integrations/serviceChannel/client.js';
import webexService from './webexService.js';
const CARD_DEDUP_TTL_MS = 5 * 60 * 1000;
const _recentInvoiceCards = new Map();
function _pruneDedupCache(now = Date.now()) {
for (const [k, ts] of _recentInvoiceCards.entries()) {
if (now - ts > CARD_DEDUP_TTL_MS) _recentInvoiceCards.delete(k);
}
}
function _formatMoney(n) {
const v = Number(n);
return Number.isFinite(v) ? `$${v.toFixed(2)}` : 'N/A';
}
function _invoiceStatusPrimary(invoice) {
if (!invoice) return '';
if (typeof invoice.Status === 'string') return invoice.Status;
return invoice.Status?.Primary || invoice.Status?.Name || '';
}
function _isInvoiceApproved(invoice) {
const status = _invoiceStatusPrimary(invoice).toLowerCase();
return status === 'approved' || status.includes('approved');
}
function _isInvoiceRejected(invoice) {
const status = _invoiceStatusPrimary(invoice).toLowerCase();
return status === 'rejected' || status.includes('rejected');
}
function getPendingInvoiceCard(db, workOrderId) {
return new Promise((resolve, reject) => {
db.get(
'SELECT workOrderId, roomId, messageId, invoiceId, postedAt FROM pending_invoice_approval_cards WHERE workOrderId = ?',
[workOrderId],
(err, row) => (err ? reject(err) : resolve(row || null))
);
});
}
function savePendingInvoiceCard(db, { workOrderId, roomId, messageId, invoiceId }) {
return new Promise((resolve, reject) => {
db.run(
`INSERT OR REPLACE INTO pending_invoice_approval_cards (workOrderId, roomId, messageId, invoiceId, postedAt)
VALUES (?, ?, ?, ?, ?)`,
[workOrderId, roomId, messageId, invoiceId ?? null, new Date().toISOString()],
(err) => (err ? reject(err) : resolve())
);
});
}
function clearPendingInvoiceCard(db, workOrderId) {
return new Promise((resolve, reject) => {
db.run(
'DELETE FROM pending_invoice_approval_cards WHERE workOrderId = ?',
[workOrderId],
(err) => (err ? reject(err) : resolve())
);
});
}
async function deleteInvoiceCardMessage(messageId) {
if (!messageId) return;
try {
const botClientMod = await import('../integrations/webex/botClient.js');
await botClientMod.default.deleteMessage(messageId);
logger('invoiceApproval', `Deleted invoice card message ${messageId}`);
} catch (err) {
logger('invoiceApproval', `Could not delete invoice card ${messageId}: ${err.message}`, 'warn');
}
}
async function _removeInvoiceCard(bot, action) {
const messageId = action?.messageId;
if (!messageId) return;
if (typeof bot?.censor === 'function') {
try {
await bot.censor(messageId);
logger('invoiceApproval:submit', `Removed invoice card message ${messageId}`);
return;
} catch (err) {
logger('invoiceApproval:submit', `bot.censor failed for ${messageId}: ${err.message}`, 'warn');
}
}
await deleteInvoiceCardMessage(messageId);
}
export function buildInvoiceApprovalAdaptiveCard(woContext, invoiceContext = {}) {
const woId = woContext?.workOrderId ?? woContext?.Id;
const woNum = woContext?.woNumber ?? woContext?.WorkorderNumber ?? woId ?? '???';
const woLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${woId || woNum}`;
const invoiceNumber = invoiceContext.invoiceNumber ?? '?';
const invoiceTotal = invoiceContext.invoiceTotal;
const approvalCode = String(invoiceContext.approvalCode ?? '').trim();
const category = String(invoiceContext.category ?? '').trim();
const invoiceId = invoiceContext.invoiceId;
const body = [
{
type: 'TextBlock',
text: 'Invoice Approval Required',
size: 'medium',
weight: 'bolder',
},
{
type: 'TextBlock',
text: `[WO-${woNum}](${woLink})`,
isSubtle: true,
wrap: true,
},
{
type: 'FactSet',
facts: [
{ title: 'Invoice #', value: String(invoiceNumber) },
{ title: 'Amount', value: _formatMoney(invoiceTotal) },
{ title: 'Approval Code', value: approvalCode || '(missing — approve in SC)' },
],
},
];
if (!approvalCode) {
body.push({
type: 'TextBlock',
text: 'This work order has no Approval Code in ServiceChannel. Add one in SC before approving here.',
color: 'attention',
wrap: true,
spacing: 'medium',
});
}
body.push(
{
type: 'TextBlock',
text: 'Category (for SC invoice approval)',
weight: 'bolder',
spacing: 'medium',
},
{
type: 'Input.Text',
id: 'category',
value: category,
placeholder: 'Category',
},
{
type: 'TextBlock',
text: 'Optional comment',
spacing: 'small',
},
{
type: 'Input.Text',
id: 'comment',
placeholder: 'Approval or rejection notes',
isMultiline: true,
}
);
const actions = [
{
type: 'Action.Submit',
title: 'Approve Invoice',
data: {
action: 'approveInvoice',
workOrderId: woId,
invoiceId,
approvalCode,
category,
},
},
{
type: 'Action.Submit',
title: 'Reject Invoice',
data: {
action: 'rejectInvoice',
workOrderId: woId,
invoiceId,
},
},
{
type: 'Action.Submit',
title: 'Cancel',
associatedInputs: 'none',
data: {
action: 'dismissInvoiceCard',
workOrderId: woId,
invoiceId,
},
},
];
return {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.3',
body,
actions,
};
}
export async function postInvoiceApprovalCard({ db, webex, roomId, workOrderId, woNumber = null }) {
if (!db || !webex || !roomId || !workOrderId) return;
const dedupKey = `wo:${workOrderId}:invoice`;
const now = Date.now();
_pruneDedupCache(now);
const lastPostedAt = _recentInvoiceCards.get(dedupKey);
if (lastPostedAt && (now - lastPostedAt) < CARD_DEDUP_TTL_MS) {
logger(
'invoiceApproval',
`Skipping duplicate invoice card for WO ${workOrderId} (posted ${Math.round((now - lastPostedAt) / 1000)}s ago)`
);
return;
}
_recentInvoiceCards.set(dedupKey, now);
try {
const woStatus = await getWorkOrderStatus(workOrderId);
if (!woStatus?.invoiceId) {
logger('invoiceApproval', `No invoice on WO ${workOrderId} — skipping card`);
_recentInvoiceCards.delete(dedupKey);
return;
}
const invoiceDetails = await getInvoice(woStatus.invoiceId);
if (invoiceDetails && (_isInvoiceApproved(invoiceDetails) || _isInvoiceRejected(invoiceDetails))) {
logger(
'invoiceApproval',
`Invoice ${woStatus.invoiceId} already ${_invoiceStatusPrimary(invoiceDetails)} — skipping card`
);
_recentInvoiceCards.delete(dedupKey);
return;
}
const invoiceContext = {
invoiceId: woStatus.invoiceId,
invoiceNumber: woStatus.invoiceNumber ?? invoiceDetails?.Number,
invoiceTotal: woStatus.invoiceTotal ?? invoiceDetails?.InvoiceTotal ?? invoiceDetails?.Total,
approvalCode: woStatus.approvalCode,
category: woStatus.category,
};
const card = buildInvoiceApprovalAdaptiveCard(
{ workOrderId, woNumber: woNumber || woStatus.woNumber },
invoiceContext
);
const sender = webex && typeof webex.sendAdaptiveCard === 'function' ? webex : webexService;
const fallback =
`Invoice approval for WO-${woNumber || woStatus.woNumber}. Invoice #${invoiceContext.invoiceNumber || woStatus.invoiceId}.`;
const existing = await getPendingInvoiceCard(db, workOrderId);
if (existing?.messageId) {
await deleteInvoiceCardMessage(existing.messageId);
}
const cardMsg = await sender.sendAdaptiveCard(roomId, card, fallback);
if (cardMsg?.id) {
await savePendingInvoiceCard(db, {
workOrderId,
roomId,
messageId: cardMsg.id,
invoiceId: woStatus.invoiceId,
});
}
logger(
'invoiceApproval',
`Posted invoice approval card for WO-${woNumber || woStatus.woNumber} in room ${roomId} (invoiceId=${woStatus.invoiceId}, messageId=${cardMsg?.id || 'n/a'})`
);
} catch (err) {
_recentInvoiceCards.delete(dedupKey);
logger('invoiceApproval', `Failed to post invoice card for WO ${workOrderId}: ${err.message}`, 'error');
throw err;
}
}
function _isInvoiceResolved(noteData) {
const note = String(noteData || '');
if (/Invoice\s*#?\s*\d+\s+has been approved/i.test(note)) return true;
if (/Invoice\s*#?\s*\d+\s+has been rejected/i.test(note)) return true;
if (/Invoice has been approved/i.test(note)) return true;
if (/Invoice has been rejected/i.test(note)) return true;
return false;
}
export async function removeInvoiceCardIfResolved({ db, workOrderId, noteData }) {
if (!db || !workOrderId) return;
if (!_isInvoiceResolved(noteData)) return;
try {
const row = await getPendingInvoiceCard(db, workOrderId);
if (!row?.messageId) return;
await deleteInvoiceCardMessage(row.messageId);
await clearPendingInvoiceCard(db, workOrderId);
logger('invoiceApproval', `Removed stale invoice card for WO ${workOrderId} (resolved in ServiceChannel)`);
} catch (err) {
logger('invoiceApproval', `Failed to remove resolved invoice card for WO ${workOrderId}: ${err.message}`, 'warn');
}
}
export async function handleInvoiceApprovalSubmit(bot, trigger, { db = null } = {}) {
const action = trigger?.attachmentAction;
if (!action || !action.inputs) return;
const inputs = action.inputs;
const actionType = inputs.action;
if (actionType === 'dismissInvoiceCard') {
const woId = inputs.workOrderId;
await _removeInvoiceCard(bot, action);
if (db && woId) {
try {
await clearPendingInvoiceCard(db, woId);
} catch (err) {
logger('invoiceApproval:submit', `Could not clear pending invoice card for WO ${woId}: ${err.message}`, 'warn');
}
}
try {
await bot.say('Invoice approval card dismissed. Approve in ServiceChannel when ready.');
} catch (_) {}
return;
}
if (actionType !== 'approveInvoice' && actionType !== 'rejectInvoice') return;
const woId = inputs.workOrderId;
const invoiceId = inputs.invoiceId;
const comment = String(inputs.comment || '').trim();
const category = String(inputs.category ?? '').trim();
if (!woId || !invoiceId) {
try {
await bot.say('Missing work order or invoice in submission.');
} catch (_) {}
return;
}
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('invoiceApproval:submit', `Could not resolve person ${action.personId}: ${e.message}`, 'warn');
}
if (actionType === 'approveInvoice') {
const approvalCode = String(inputs.approvalCode ?? '').trim();
if (!approvalCode) {
try {
await bot.say('Cannot approve: this work order has no Approval Code in ServiceChannel. Add one in SC or approve manually.');
} catch (_) {}
return;
}
try {
await approveInvoice(invoiceId, {
approvalCode,
comments: comment || `Approved by ${approver} via ServChan`,
category,
});
await _removeInvoiceCard(bot, action);
if (db) {
await clearPendingInvoiceCard(db, woId);
}
await bot.say({
markdown:
`✅ **Invoice approved** by **${approver}** for WO **${woId}**.` +
(comment ? `\n\nComment: ${comment}` : ''),
});
logger('invoiceApproval:submit', `Approved invoice ${invoiceId} for WO ${woId} by ${approver}`);
} catch (err) {
const msg = err.message || String(err);
const isTokenError = msg.includes('token fetch failed');
const isMli = msg.includes('503') || msg.toLowerCase().includes('mli');
const reply = isTokenError
? `❌ **ServiceChannel login failed** — fix SC credentials and restart ServChan, then retry.`
: isMli
? `❌ Invoice approval via API is unavailable (MLI enabled). Approve manually in ServiceChannel.\n\nDetails: ${msg}`
: `❌ Failed to approve invoice: ${msg}. Approve manually in ServiceChannel.`;
await bot.say({ markdown: reply });
logger('invoiceApproval:submit', `Approve failed for invoice ${invoiceId}: ${msg}`, 'error');
}
return;
}
// rejectInvoice
try {
await rejectInvoice(invoiceId, {
comments: comment || `Rejected by ${approver} via ServChan`,
isNotifyProvider: true,
});
await _removeInvoiceCard(bot, action);
if (db) {
await clearPendingInvoiceCard(db, woId);
}
await bot.say({
markdown:
`Invoice **rejected** by **${approver}** for WO **${woId}**.` +
(comment ? `\n\nComment: ${comment}` : ''),
});
logger('invoiceApproval:submit', `Rejected invoice ${invoiceId} for WO ${woId} by ${approver}`);
} catch (err) {
const msg = err.message || String(err);
await bot.say({ markdown: `❌ Failed to reject invoice: ${msg}. Reject manually in ServiceChannel.` });
logger('invoiceApproval:submit', `Reject failed for invoice ${invoiceId}: ${msg}`, 'error');
}
}
export default {
buildInvoiceApprovalAdaptiveCard,
postInvoiceApprovalCard,
removeInvoiceCardIfResolved,
handleInvoiceApprovalSubmit,
};

View file

@ -4,10 +4,13 @@ import botClient from '../integrations/webex/botClient.js';
import { logger } from '../utils/logger.js';
import defaultDb from '../db/mappings.js';
import { getCompletedOperationsRoomId, parseServChanRoomTitle } from '../utils/servchanRoomTitle.js';
import { getWoCloseout } from './woCloseoutService.js';
const REMOVE_OTHERS_AFTER_DAYS = 14;
const DELETE_AFTER_DAYS = 60;
const REMINDER_AFTER_DAYS = parseInt(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60', 10);
const CLEANUP_PROVIDER_NAME = (process.env.SPACE_CLEANUP_PROVIDER_NAME || 'Pro-Motion Technology Group, LLC').trim();
const CLEANUP_PROVIDER_ID = parseInt(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215', 10);
/** COMPLETED + these extended values → auto-remove eligible. */
const AUTO_REMOVE_EXTENDED = new Set(['CONFIRMED', 'CANCELLED']);
@ -25,6 +28,22 @@ function isNoChargeExtended(extendedRaw) {
return extended === 'NO CHARGE' || extended.includes('NO CHARGE');
}
export function matchesCleanupProvider(providerName, providerId = null) {
const hasNameFilter = Boolean(CLEANUP_PROVIDER_NAME);
const hasIdFilter = Number.isFinite(CLEANUP_PROVIDER_ID) && CLEANUP_PROVIDER_ID > 0;
if (!hasNameFilter && !hasIdFilter) return true;
if (hasIdFilter && providerId != null && Number(providerId) === CLEANUP_PROVIDER_ID) {
return true;
}
if (hasNameFilter) {
return (providerName || '').trim().toLowerCase() === CLEANUP_PROVIDER_NAME.toLowerCase();
}
return false;
}
/**
* Classify a work order for space cleanup.
* @returns {{ category: 'auto_remove'|'remind'|'skip', reason: string }}
@ -76,12 +95,17 @@ function buildConsolidatedDigestMessage(digestRows) {
for (const row of digestRows) {
const header = row.headerLine || `WO-${row.woNumber || row.workOrderId}`;
md += `- **${header}** — ${row.statusDisplay}${row.ageDays} days\n`;
let line = `- **${header}** — ${row.statusDisplay}`;
if (row.isInvoiced) {
line += row.invoiceNumber ? ` — invoiced #${row.invoiceNumber}` : ' — invoiced';
}
line += `${row.ageDays} days\n`;
md += line;
}
md +=
'\nTerminal statuses (Confirmed, Cancelled, No Charge) trigger auto-remove. ' +
'Use `/completed` in the WO space when verified working.';
'After an invoice is posted, use `/confirmed` in the WO space when resolution is verified.';
return md;
}
@ -134,6 +158,7 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
let deleted = 0;
let digestPosted = 0;
let skipped = 0;
let providerFiltered = 0;
let failed = 0;
try {
@ -159,10 +184,36 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
continue;
}
if (!matchesCleanupProvider(statusInfo.providerName, statusInfo.providerId)) {
const providerLabel = statusInfo.providerName
? `${statusInfo.providerName}${statusInfo.providerId ? ` (${statusInfo.providerId})` : ''}`
: (statusInfo.providerId ? `ID ${statusInfo.providerId}` : 'UNKNOWN PROVIDER');
results.push({
woId: mapping.workOrderId,
roomId: mapping.roomId,
action: 'skipped',
status: providerLabel,
reason: `provider excluded (cleanup limited to ${CLEANUP_PROVIDER_NAME}${Number.isFinite(CLEANUP_PROVIDER_ID) ? ` / ID ${CLEANUP_PROVIDER_ID}` : ''})`,
});
providerFiltered++;
skipped++;
continue;
}
const primary = normalizePrimary(statusInfo.primaryStatus);
const extended = normalizeExtended(statusInfo.extendedStatus);
const statusDisplay = extended ? `${primary} / ${extended}` : primary;
const classification = classifyWorkOrderCleanup(primary, extended);
let classification = classifyWorkOrderCleanup(primary, extended);
const closeout = await getWoCloseout(db, mapping.workOrderId);
if (closeout && primary === 'COMPLETED') {
classification = {
category: 'auto_remove',
reason: closeout.scStatusUpdated
? 'ServChan close-out recorded (SC CONFIRMED)'
: 'ServChan close-out recorded (SC note only)',
};
}
if (classification.category === 'skip') {
results.push({
@ -176,7 +227,9 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
continue;
}
const ageDays = daysSince(statusInfo.updatedDate);
const ageDays = closeout?.confirmedAt
? daysSince(closeout.confirmedAt)
: daysSince(statusInfo.updatedDate);
if (classification.category === 'remind') {
remindEligible++;
@ -214,6 +267,8 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
ageDays,
roomId: mapping.roomId,
headerLine,
isInvoiced: statusInfo.isInvoiced === true,
invoiceNumber: statusInfo.invoiceNumber ?? null,
});
results.push({
@ -378,6 +433,9 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
digestPosted,
digestWouldPost: dryRun && digestWouldPost && digestRows.length > 0,
digestSkippedReason,
providerFiltered,
cleanupProvider: CLEANUP_PROVIDER_NAME || null,
cleanupProviderId: Number.isFinite(CLEANUP_PROVIDER_ID) ? CLEANUP_PROVIDER_ID : null,
removedOthers,
deleted,
skipped,

View file

@ -34,6 +34,7 @@
import { Mutex } from 'async-mutex';
import { logger } from '../utils/logger.js';
import approvalService from './approvalService.js';
import invoiceApprovalService from './invoiceApprovalService.js';
import attachmentService from './attachmentService.js';
export function createWebhookProcessor(options = {}) {
@ -232,6 +233,14 @@ export function createWebhookProcessor(options = {}) {
logger('webhookProcessor:approval', `Card remove error for ${workOrderId}: ${e.message}`, 'warn');
});
invoiceApprovalService.removeInvoiceCardIfResolved({
db,
workOrderId,
noteData: noteDataForApproval,
}).catch((e) => {
logger('webhookProcessor:invoiceApproval', `Card remove error for ${workOrderId}: ${e.message}`, 'warn');
});
// NEW: Proposal approval card for WAITING FOR APPROVAL status (additive to text note)
const ext = (p.Object?.Status?.Extended || '').toUpperCase();
if (ext.includes('WAITING FOR APPROVAL')) {

View file

@ -0,0 +1,38 @@
/**
* ServChan-recorded WO close-outs (when SC status API cannot set CONFIRMED).
*/
export function getWoCloseout(db, workOrderId) {
return new Promise((resolve, reject) => {
db.get(
`SELECT workOrderId, confirmedAt, confirmedBy, scStatusUpdated, noteText
FROM wo_closeouts WHERE workOrderId = ?`,
[workOrderId],
(err, row) => (err ? reject(err) : resolve(row || null))
);
});
}
export function saveWoCloseout(db, { workOrderId, confirmedBy, scStatusUpdated, noteText }) {
return new Promise((resolve, reject) => {
db.run(
`INSERT OR REPLACE INTO wo_closeouts (workOrderId, confirmedAt, confirmedBy, scStatusUpdated, noteText)
VALUES (?, ?, ?, ?, ?)`,
[
workOrderId,
new Date().toISOString(),
confirmedBy,
scStatusUpdated ? 1 : 0,
noteText ?? null,
],
(err) => (err ? reject(err) : resolve())
);
});
}
export async function hasWoCloseout(db, workOrderId) {
const row = await getWoCloseout(db, workOrderId);
return Boolean(row);
}
export default { getWoCloseout, saveWoCloseout, hasWoCloseout };

View file

@ -0,0 +1,57 @@
/**
* Shared guards for commands that only run in mapped ServChan WO spaces.
*/
import { parseServChanRoomTitle } from './servchanRoomTitle.js';
const WO_SPACE_MSG = 'This command can only be used in a ServChan work order space.';
function lookupWorkOrderIdByRoom(db, roomId) {
return new Promise((resolve, reject) => {
db.get(
'SELECT workOrderId FROM mappings WHERE roomId = ?',
[roomId],
(err, row) => (err ? reject(err) : resolve(row?.workOrderId ?? null))
);
});
}
/**
* @returns {Promise<{ ok: true, workOrderId, roomId, parsed, room } | { ok: false, error: string }>}
*/
export async function resolveWoRoomContext({ db, botClient, roomId, isGroup }) {
if (!isGroup || !roomId) {
return { ok: false, error: WO_SPACE_MSG };
}
const workOrderId = await lookupWorkOrderIdByRoom(db, roomId);
if (!workOrderId) {
return { ok: false, error: WO_SPACE_MSG };
}
let room;
try {
room = await botClient.getRoom(roomId);
} catch {
return { ok: false, error: 'Could not load this Webex space. Please try again.' };
}
const parsed = parseServChanRoomTitle(room?.title);
if (!parsed) {
return { ok: false, error: WO_SPACE_MSG };
}
return { ok: true, workOrderId, roomId, parsed, room };
}
export async function resolveDisplayName(botClient, personId) {
if (!personId) return 'Unknown User';
try {
const person = await botClient.getPersonDetails(personId);
return person?.displayName || person?.emails?.[0] || 'Unknown User';
} catch {
return 'Unknown User';
}
}
export default { resolveWoRoomContext, resolveDisplayName };