servchan/src/services/webhookProcessor.js
jmcqueen 0fc3eb38fa Add /createWO, WO file upload, and suppress SC upload webhook echo.
Enable ServiceChannel work order creation from DM, upload attachments from
WO spaces via /attach and /upload, and skip note/attachment echo for
bot-originated uploads. Also fixes duplicate FedEx notifications and adds
multi-instance command dedup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:22:21 -04:00

315 lines
No EOL
12 KiB
JavaScript

/**
* webhookProcessor.js
*
* Core ServiceChannel → Webex webhook handling logic.
*
* Extracted during the 2026-05-28 cleanup refactor from the original monolith in index.js.
*
* This module owns:
* - Per-workOrderId mutex + queue to safely handle bursty ServiceChannel events
* - Room creation / lookup via mappings DB
* - Member seeding (first time only)
* - Message formatting per EventType (including xAI summarization for new WOs)
* - Posting to the correct Webex space
*
* IMPORTANT DB NOTE (production constraint):
* This processor does NOT create its own database connection.
* The caller must pass in the active sqlite3 `db` instance.
* The physical DB file used by production must never be moved or renamed.
*
* Usage (during transition):
* import { createWebhookProcessor } from './webhookProcessor.js';
* const processor = createWebhookProcessor({
* db,
* webex: botClient,
* summarizeDescription: mySummarizer,
* teamId: process.env.WEBEX_TEAM_ID,
* defaultMembers: [...],
* });
*
* // Then in the webhook route:
* await processor.processWebhook(payload);
*/
import { Mutex } from 'async-mutex';
import { logger } from '../utils/logger.js';
import approvalService from './approvalService.js';
import invoiceApprovalService from './invoiceApprovalService.js';
import shipmentTrackingService from './shipmentTrackingService.js';
import attachmentService from './attachmentService.js';
import {
shouldSuppressAttachmentPost,
shouldSuppressWebhookNote,
} from '../db/uploadEchoSuppress.js';
export function createWebhookProcessor(options = {}) {
const {
db,
webex, // expected to have: createRoom, addMember, sendMarkdown, getRoom?
summarizeDescription, // async (rawDescription, xaiToken, opts?) => { summary }
teamId,
defaultMembers = [],
xaiToken, // for the simple initial-description summarizer
// secrets are now loaded directly inside the modules that need them
} = options;
if (!db) {
throw new Error('webhookProcessor requires a sqlite3 db instance');
}
if (!webex) {
throw new Error('webhookProcessor requires a webex client (botClient)');
}
// Per-workOrderId concurrency control (private to this processor instance)
const mutexes = new Map(); // workOrderId → Mutex
const pendingQueues = new Map(); // workOrderId → Array<{payload, startTime}>
function scheduleAttachmentPost({ roomId, workOrderId, woNumber, attachmentIds = null }) {
attachmentService.postWorkOrderAttachments({
db,
webex,
roomId,
workOrderId,
woNumber,
attachmentIds,
}).catch((err) => {
logger('webhookProcessor:attachments', `Post error for WO ${workOrderId}: ${err.message}`, 'warn');
});
}
/**
* Main entry point — call this for every incoming ServiceChannel webhook payload.
* Safe to call concurrently; internal mutex + queue handles ordering per WO.
*/
async function processWebhook(payload) {
const startTime = Date.now();
const { Object: obj } = payload || {};
if (!obj || !obj.Id) {
logger('webhookProcessor', 'Invalid payload — missing Object.Id');
return;
}
const workOrderId = obj.Id;
// Ensure we have a mutex and queue for this work order
if (!mutexes.has(workOrderId)) {
mutexes.set(workOrderId, new Mutex());
}
const mutex = mutexes.get(workOrderId);
if (!pendingQueues.has(workOrderId)) {
pendingQueues.set(workOrderId, []);
}
const queue = pendingQueues.get(workOrderId);
queue.push({ payload, startTime });
let release;
try {
release = await mutex.acquire();
// 1. Find or create the Webex room for this work order
const row = await new Promise((resolve, reject) => {
db.get('SELECT roomId FROM mappings WHERE workOrderId = ?', [workOrderId], (err, r) => {
err ? reject(err) : resolve(r);
});
});
let roomId = row?.roomId;
let roomJustCreated = false;
if (!roomId) {
logger('webhookProcessor', `Creating new room for WO-${workOrderId}`);
const storeLabel = obj.LocationStoreId != null && obj.LocationStoreId !== ''
? String(obj.LocationStoreId)
: '?';
const title = `ServChan WO-${obj.Number} | Store ${storeLabel} | ${obj.LocationName || ''}`;
const created = await webex.createRoom(title, teamId);
roomId = created.id;
await new Promise((resolve, reject) => {
db.run(
'INSERT OR REPLACE INTO mappings (workOrderId, roomId) VALUES (?, ?)',
[workOrderId, roomId],
(err) => (err ? reject(err) : resolve())
);
});
logger('webhookProcessor', `Created space ${roomId} for WO-${obj.Number}`);
roomJustCreated = true;
// Seed default members (best effort)
if (defaultMembers.length > 0) {
await Promise.allSettled(
defaultMembers.map(email => webex.addMember(roomId, email))
).then(results => {
results.forEach((r, i) => {
if (r.status === 'rejected') {
logger('webhookProcessor:addMember', `${defaultMembers[i]} failed: ${r.reason?.message || r.reason}`);
}
});
});
}
}
// 2. Drain the queue and post messages for this work order
while (queue.length > 0) {
const { payload: p, startTime: itemStart } = queue.shift();
let text = '';
let suppressRoomPost = false;
if (p.EventType === 'WorkOrderCreated') {
let summaryResult;
try {
summaryResult = summarizeDescription
? await summarizeDescription(p.Object.Description, xaiToken, { maxTokens: 300 })
: { summary: p.Object.Description?.substring(0, 300) || '' };
} catch (sumErr) {
logger('webhookProcessor', `Summarization failed for new WO ${p.Object.Id}: ${sumErr.message}`, 'warn');
summaryResult = { summary: p.Object.Description?.substring(0, 400) || 'No description' };
}
// LocationStoreId can arrive as a numeric string, a number, or be
// missing. Old code did `LocationStoreId * 1` which produced NaN
// when the field was absent and dropped leading zeros for strings.
const storeLabel = p.Object.LocationStoreId != null && p.Object.LocationStoreId !== ''
? String(p.Object.LocationStoreId)
: '?';
text =
`## [New Work Order Created](https://www.servicechannel.com/sc/wo/Workorders/index?id=${p.Object.Id})\n\n` +
`### ServChan WO-${p.Object.Number} | Store ${storeLabel} | ${p.Object.LocationName || ''}\n` +
`**Trade:** ${p.Object.Trade || 'N/A'} | ${p.Object.ProviderName || 'N/A'}\n` +
`**Priority:** ${p.Object.Priority || 'N/A'} | ${p.Object.Category || 'N/A'} | ${p.Object.ProblemCode || 'N/A'}\n` +
`**Status:** ${p.Object.Status?.Primary || 'N/A'} | ${p.Object.Status?.Extended || 'N/A'}\n` +
`**Description:** ${summaryResult?.summary || p.Object.Description?.substring(0, 300) || 'No description'}\n\n`;
} else if (p.EventType === 'WorkOrderNoteAdded') {
const note = p.Object.Notes?.[0] || {};
if (await shouldSuppressWebhookNote(db, workOrderId, note)) {
suppressRoomPost = true;
logger('webhookProcessor', `Suppressed note echo for WO ${workOrderId}`);
} else {
text =
`### [Note Added](https://www.servicechannel.com/sc/wo/Workorders/index?id=${p.Object.Id})\n\n` +
`${note.NoteData || 'No note content'}${note.CreatedBy || 'Unknown'}\n\n` +
`**Status:** ${p.Object.Status?.Primary || 'N/A'} | ${p.Object.Status?.Extended || 'N/A'}`;
}
} else {
// Generic fallback for other events
text =
`**Work Order Update**\n\n` +
`**WO Number:** ${p.Object.Number || 'Unknown'}\n` +
`**Event:** ${p.EventType || 'Unknown'}\n` +
`**Current Status:** ${p.Object.Status?.Primary || 'N/A'}\n` +
`**Updated:** ${new Date().toISOString()}`;
}
if (!suppressRoomPost) {
if (!text.trim()) {
text = `Webhook received for Work Order ${workOrderId} (${p.EventType || 'unknown event'}).`;
}
try {
await webex.sendMarkdown(roomId, text);
logger('webhookProcessor', `Posted for ${workOrderId} (${Date.now() - itemStart}ms)`);
} catch (postErr) {
const details = postErr.response?.data ? JSON.stringify(postErr.response.data) : postErr.message;
logger('webhookProcessor', `Post failed for ${workOrderId}: ${details}`, 'error');
}
}
// Auto-post attachments referenced in this note
if (p.EventType === 'WorkOrderNoteAdded') {
const note = p.Object.Notes?.[0] || {};
if (note.AttachmentIds?.length) {
const suppressAttachments = suppressRoomPost
|| await shouldSuppressAttachmentPost(db, workOrderId, note.AttachmentIds);
if (!suppressAttachments) {
scheduleAttachmentPost({
roomId,
workOrderId,
woNumber: p.Object.Number,
attachmentIds: note.AttachmentIds,
});
}
}
}
// Auto-remove stale approval cards when resolved in ServiceChannel
const noteDataForApproval = (p.EventType === 'WorkOrderNoteAdded' && p.Object.Notes?.[0]?.NoteData) || null;
approvalService.removeApprovalCardIfResolved({
db,
workOrderId,
woObj: p.Object,
noteData: noteDataForApproval,
}).catch((e) => {
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');
});
if (p.EventType === 'WorkOrderNoteAdded' && noteDataForApproval) {
shipmentTrackingService.registerShipmentsFromNote({
db,
webex,
roomId,
workOrderId,
woNumber: p.Object.Number,
noteText: noteDataForApproval,
}).catch((e) => {
logger('webhookProcessor:shipmentTracking', `Register 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')) {
// Capture the note that usually contains "Proposal #12345 has been created"
// so we can look up the specific proposal and its itemized parts + labor.
const noteData = noteDataForApproval;
// fire-and-forget; uses the injected webex (WebexService) or falls back internally
approvalService.fetchAndPostApprovalCardIfNeeded(webex, roomId, p.Object, noteData, { db }).catch(e => {
logger('webhookProcessor:approval', `Card post error for ${workOrderId}: ${e.message}`, 'warn');
});
}
}
if (roomJustCreated) {
scheduleAttachmentPost({
roomId,
workOrderId,
woNumber: obj.Number,
});
}
} catch (err) {
logger('webhookProcessor', `Critical error for ${workOrderId}: ${err.message}\n${err.stack}`, 'error');
} finally {
if (release) release();
// Clean up empty queues/mutexes
if (queue.length === 0) {
pendingQueues.delete(workOrderId);
mutexes.delete(workOrderId);
}
}
}
return {
processWebhook,
// Expose internal state for debugging / the /cleanup-test style endpoints if needed later
_getInternalState: () => ({ mutexes, pendingQueues }),
};
}
export default createWebhookProcessor;