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:
parent
bd02d51111
commit
cba047cb4e
15 changed files with 953 additions and 125 deletions
|
|
@ -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 * * *
|
||||
|
|
|
|||
18
index.js
18
index.js
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
37
src/commands/woAddNote.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
159
src/commands/woConfirmed.js
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
438
src/services/invoiceApprovalService.js
Normal file
438
src/services/invoiceApprovalService.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
38
src/services/woCloseoutService.js
Normal file
38
src/services/woCloseoutService.js
Normal 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 };
|
||||
57
src/utils/woRoomContext.js
Normal file
57
src/utils/woRoomContext.js
Normal 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 };
|
||||
Loading…
Reference in a new issue