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>
315 lines
No EOL
12 KiB
JavaScript
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; |