From abd642321867f46dcea0bcaf353644239e3f9a37 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Thu, 27 Aug 2026 08:21:30 -0400 Subject: [PATCH] Route FedEx tracking updates through ServiceChannel WO notes. Write status and all-delivered messages as SC notes so Note Added webhooks update WO spaces, keep dedup via comparison keys, deeplink ops delivery headers, and skip duplicate cron posts on unchanged status. Co-authored-by: Cursor --- .env.example | 1 + index.js | 18 +- src/commands/help.js | 4 +- src/commands/woTrackStatus.js | 12 +- src/services/shipmentTrackingService.js | 230 +++++++++++++++--------- 5 files changed, 163 insertions(+), 102 deletions(-) diff --git a/.env.example b/.env.example index ff37818..23f79a0 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,7 @@ SC_WEBHOOK_AUTH_MODE=off # off | log | enforce # FEDEX_ACCOUNT_NUMBER=your-fedex-account-number # FEDEX_API_BASE=https://apis.fedex.com # SHIPMENT_TRACKING_ENABLED=true +# Status updates are written to ServiceChannel WO notes (Note Added webhook posts to the WO space). # Every 2 hours 8am–6pm Eastern (override cron/timezone if needed) # SHIPMENT_TRACKING_CRON=0 0 8,10,12,14,16,18 * * * # SHIPMENT_TRACKING_TIMEZONE=America/New_York diff --git a/index.js b/index.js index d8f00ee..f8a31bd 100644 --- a/index.js +++ b/index.js @@ -141,22 +141,8 @@ db.serialize(() => { } } ); - // Backfill (Option B) previously set lastPostedStatus on in-transit rows without - // posting to WO rooms, which blocked cron updates. Clear those so the next poll posts. - db.run( - `UPDATE shipment_tracking - SET lastPostedStatus = NULL - WHERE deliveredAt IS NULL - AND sourceNote = 'backfill' - AND lastPostedStatus IS NOT NULL`, - function onRepair(err) { - if (err) { - console.warn(`[DB] shipment_tracking backfill repair: ${err.message}`); - } else if (this.changes > 0) { - console.log(`[DB] Cleared lastPostedStatus on ${this.changes} backfilled in-transit shipment(s)`); - } - } - ); + // Removed recurring lastPostedStatus reset on startup — it forced duplicate FedEx + // posts after every container restart for backfill in-transit shipments. db.run(` CREATE TABLE IF NOT EXISTS bot_message_dedup ( messageId TEXT NOT NULL, diff --git a/src/commands/help.js b/src/commands/help.js index 9c9e36b..1185e29 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -13,9 +13,9 @@ export async function handleHelp(bot, trigger) { 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`; text += `- **/attach** or **/upload** — upload a photo or file to this work order in ServiceChannel (attach the file to your message; optional caption after the command)\n`; - text += `- **/trackStatus** — refresh FedEx shipment tracking for this WO (auto-poll every 2h, 8am–6pm ET)\n`; + text += `- **/trackStatus** — refresh FedEx shipment tracking for this WO (auto-poll every 2h, 8am–6pm ET; updates are added as SC notes)\n`; text += `- **/trackTest** — one-off FedEx API lookup (not stored; for testing)\n`; - text += `\nFedEx tracking numbers in SC notes (e.g. \`FEDEX 876239420601\`) are registered automatically. Delivered packages notify the ops space.\n`; + text += `\nFedEx tracking numbers in SC notes (e.g. \`FEDEX 876239420601\`) are registered automatically. Status changes are written to ServiceChannel notes and appear in this space via Note Added webhooks. Delivered packages notify the ops space.\n`; } else { text += `- **/createWO** — create a new ServiceChannel work order (Adaptive Card form)\n`; text += `- **/woSummary ** — status of any work order\n`; diff --git a/src/commands/woTrackStatus.js b/src/commands/woTrackStatus.js index b37061c..9d714ac 100644 --- a/src/commands/woTrackStatus.js +++ b/src/commands/woTrackStatus.js @@ -48,12 +48,22 @@ export async function handleWoTrackStatus(bot, trigger) { return; } - const lines = (result.shipments || []).map((s) => formatShipmentStatusMarkdown(s)); const summary = result.checked === 1 ? '1 shipment checked' : `${result.checked} shipments checked`; + if (result.updated === 0 && !result.delivered) { + await bot.say({ + markdown: + `**FedEx tracking** — no status change since the last update.\n\n` + + `_${summary}_`, + }); + return; + } + + const lines = (result.shipments || []).map((s) => formatShipmentStatusMarkdown(s)); + await bot.say({ markdown: `**FedEx tracking refresh**\n\n` + diff --git a/src/services/shipmentTrackingService.js b/src/services/shipmentTrackingService.js index 96d6e12..138073c 100644 --- a/src/services/shipmentTrackingService.js +++ b/src/services/shipmentTrackingService.js @@ -1,10 +1,10 @@ /** - * FedEx shipment tracking: parse SC notes, persist, poll, post to WO rooms. + * FedEx shipment tracking: parse SC notes, persist, poll, write SC WO notes. */ import { logger } from '../utils/logger.js'; import pLimit from 'p-limit'; -import { fetchWithRetry } from '../integrations/serviceChannel/client.js'; +import { fetchWithRetry, addWorkOrderNote } from '../integrations/serviceChannel/client.js'; import { isFedExConfigured, isShipmentTrackingEnabled, @@ -21,9 +21,47 @@ const BATCH_SIZE = 30; const BACKFILL_CONCURRENCY = 2; const FEDEX_TRACKING_RE = /\bFEDEX\s+([\d,\s]+)/gi; const FEDEX_NUMBER_RE = /^\d{12,22}$/; +const FEDEX_NOTE_PREFIX = 'ServChan FedEx update:'; let bootLogged = false; +function workOrderScUrl(workOrderId) { + return `https://www.servicechannel.com/sc/wo/Workorders/index?id=${encodeURIComponent(workOrderId)}`; +} + +async function resolveWoNumberFromRow(row) { + try { + const botClientMod = await import('../integrations/webex/botClient.js'); + const room = await botClientMod.default.getRoom(row.roomId); + const parsed = parseServChanRoomTitle(room?.title); + return parsed?.woNumber || String(row.workOrderId); + } catch { + return String(row.workOrderId); + } +} + +function buildFedExStatusNoteLine(trackingNumber, statusLabel, delivered = false) { + let line = `FedEx ${trackingNumber}: ${statusLabel}`; + if (delivered) line += ' · Delivered'; + return line; +} + +function buildFedExConsolidatedNoteText(entries) { + const lines = entries.map((entry) => + buildFedExStatusNoteLine(entry.trackingNumber, entry.statusLabel, entry.delivered) + ); + return `${FEDEX_NOTE_PREFIX}\n${lines.join('\n')}`; +} + +function buildAllDeliveredNoteText(woNumber) { + return `${FEDEX_NOTE_PREFIX} All parts shipments delivered for WO-${woNumber}.`; +} + +async function postFedExScNote(workOrderId, noteText) { + await addWorkOrderNote(workOrderId, noteText); + logger('shipmentTracking', `Posted SC note for WO ${workOrderId}`); +} + export function parseFedExTrackingNumbers(noteText) { if (!noteText) return []; @@ -59,12 +97,33 @@ function formatStatusLabelFromResult(result) { return formatFedExStatusLine(result); } +/** Stable key for change detection — excludes volatile ETA time windows. */ +function buildStatusComparisonKey(result) { + const code = String(result?.statusCode || '').toUpperCase(); + const desc = String(result?.statusDescription || '').trim(); + let day = ''; + if (result?.deliveredAt) { + day = new Date(result.deliveredAt).toDateString(); + } else if (result?.estimatedDelivery) { + const d = new Date(result.estimatedDelivery); + if (!Number.isNaN(d.getTime())) day = d.toDateString(); + } + return `${code}::${desc}::${day}`; +} + +function isStoredComparisonKey(stored) { + return typeof stored === 'string' && /^[A-Z0-9]{1,4}::/.test(stored); +} + function hasShipmentStatusChanged(row, result) { + const newKey = buildStatusComparisonKey(result); const newLabel = formatStatusLabelFromResult(result); - // User-visible status is the source of truth. FedEx may flip statusCode or ISO - // timestamps between polls while the formatted line stays identical. if (row.lastPostedStatus) { + if (isStoredComparisonKey(row.lastPostedStatus)) { + return newKey !== row.lastPostedStatus; + } + // Legacy rows stored the full display label before comparison-key format. return newLabel !== row.lastPostedStatus; } @@ -159,6 +218,7 @@ async function applyBackfillTrackResult(db, row, result) { } const statusLabel = formatStatusLabelFromResult(result); + const comparisonKey = buildStatusComparisonKey(result); const delivered = isDelivered(result.statusCode, result.statusDescription); const deliveredAt = delivered ? (result.deliveredAt || now) : null; @@ -168,10 +228,8 @@ async function applyBackfillTrackResult(db, row, result) { estimatedDelivery: result.estimatedDelivery, deliveredAt, lastCheckedAt: now, - // Only mark as "already posted" for delivered packages (Option B — suppress - // ops spam). In-transit rows keep lastPostedStatus NULL so the next cron - // posts the current status once to the WO room. - lastPostedStatus: delivered ? statusLabel : null, + // Delivered: mark posted so cron won't re-notify. In-transit stays NULL for first cron post. + lastPostedStatus: delivered ? comparisonKey : null, }); if (delivered) { @@ -242,6 +300,16 @@ function dbMarkOpsDeliveredNotified(db, workOrderId, trackingNumber) { }); } +function dbUpdateLastPostedStatus(db, trackingNumber, workOrderId, lastPostedStatus) { + return new Promise((resolve, reject) => { + db.run( + `UPDATE shipment_tracking SET lastPostedStatus = ? WHERE workOrderId = ? AND trackingNumber = ?`, + [lastPostedStatus, workOrderId, trackingNumber], + (err) => (err ? reject(err) : resolve()) + ); + }); +} + async function resolveSpaceHeaderLine(roomId, workOrderId) { try { const botClientMod = await import('../integrations/webex/botClient.js'); @@ -264,7 +332,10 @@ async function notifyOpsOnDelivery(webex, row) { } const headerLine = await resolveSpaceHeaderLine(row.roomId, row.workOrderId); - const md = `${headerLine}\nFedEx ${row.trackingNumber} — successfully delivered`; + const woLink = workOrderScUrl(row.workOrderId); + const md = + `[${headerLine}](${woLink})\n` + + `FedEx ${row.trackingNumber} — successfully delivered`; const sender = getSender(webex); try { @@ -359,7 +430,7 @@ export async function registerShipmentsFromNote({ const md = `**FedEx shipment tracking registered** for ${woLabel}\n\n` + `${lines.join('\n')}\n\n` + - `ServChan will post status updates every 2 hours (8am–6pm Eastern) while in transit.`; + `ServChan will add status updates to ServiceChannel notes every 2 hours (8am–6pm Eastern) while in transit.`; try { await sender.sendMarkdown(roomId, md); @@ -372,7 +443,7 @@ export async function registerShipmentsFromNote({ `Registered ${newlyRegistered.length} tracking number(s) for WO ${workOrderId}: ${newlyRegistered.join(', ')}` ); - // Poll FedEx immediately so the WO room gets a first status post without + // Poll FedEx immediately so the WO gets a first status SC note (via webhook) without // waiting for the next cron window. try { const rows = []; @@ -418,64 +489,60 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId, { suppre } const statusLabel = formatStatusLabel(result); + const comparisonKey = buildStatusComparisonKey(result); const delivered = isDelivered(result.statusCode, result.statusDescription); const deliveredAt = delivered ? (result.deliveredAt || now) : null; const statusChanged = hasShipmentStatusChanged(row, result); const newlyDelivered = delivered && !row.deliveredAt; + const woNum = woNumberByWoId?.get(row.workOrderId) || await resolveWoNumberFromRow(row); + let pendingPost = null; + let notePosted = false; + + if (statusChanged && !suppressRoomPosts) { + const noteText = `${FEDEX_NOTE_PREFIX} ${buildFedExStatusNoteLine(row.trackingNumber, statusLabel, delivered)}`; + try { + await postFedExScNote(row.workOrderId, noteText); + notePosted = true; + } catch (err) { + logger( + 'shipmentTracking', + `SC note failed for ${row.trackingNumber} (WO ${row.workOrderId}): ${err.message}`, + 'warn' + ); + } + } else if (statusChanged && suppressRoomPosts) { + pendingPost = { + workOrderId: row.workOrderId, + roomId: row.roomId, + trackingNumber: row.trackingNumber, + statusLabel, + delivered, + woNum, + comparisonKey, + }; + logger( + 'shipmentTracking', + `Queued consolidated SC note for ${row.trackingNumber} (WO ${row.workOrderId}): ${statusLabel}`, + 'info' + ); + } else { + logger( + 'shipmentTracking', + `No SC note for ${row.trackingNumber} (WO ${row.workOrderId}): unchanged (${statusLabel})`, + 'info' + ); + } + await dbUpdateShipment(db, row.trackingNumber, row.workOrderId, { statusCode: result.statusCode, statusDescription: result.statusDescription, estimatedDelivery: result.estimatedDelivery, deliveredAt, lastCheckedAt: now, - lastPostedStatus: statusChanged ? statusLabel : row.lastPostedStatus, + lastPostedStatus: notePosted ? comparisonKey : row.lastPostedStatus, }); - const woNum = woNumberByWoId?.get(row.workOrderId) || row.workOrderId; - let pendingPost = null; - - if (statusChanged) { - pendingPost = { - roomId: row.roomId, - workOrderId: row.workOrderId, - trackingNumber: row.trackingNumber, - statusLabel, - delivered, - woNum, - }; - - if (!suppressRoomPosts) { - const sender = getSender(webex); - const md = - `**FedEx update** · WO-${woNum}\n\n` + - `Tracking **[${row.trackingNumber}](${fedExTrackUrl(row.trackingNumber)})**: ${statusLabel}` + - (delivered ? '\n\nPackage delivered.' : ''); - - try { - await sender.sendMarkdown(row.roomId, md); - logger( - 'shipmentTracking', - `Posted status update for ${row.trackingNumber} (WO ${row.workOrderId}): ${statusLabel}` - ); - } catch (err) { - logger('shipmentTracking', `Update post failed for ${row.trackingNumber}: ${err.message}`, 'warn'); - } - } else { - logger( - 'shipmentTracking', - `Suppressed WO post for ${row.trackingNumber} (WO ${row.workOrderId}): ${statusLabel}`, - 'info' - ); - } - } else { - logger( - 'shipmentTracking', - `No WO post for ${row.trackingNumber} (WO ${row.workOrderId}): unchanged (${statusLabel})`, - 'info' - ); - } - if (newlyDelivered) { const opsNotified = await notifyOpsOnDelivery(webex, row); if (opsNotified) { @@ -483,55 +550,52 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId, { suppre } } - if (delivered) { + if (delivered && !suppressRoomPosts) { const remaining = await dbCountActiveForWo(db, row.workOrderId); - if (remaining === 0 && !suppressRoomPosts) { - const sender = getSender(webex); + if (remaining === 0) { try { - await sender.sendMarkdown( - row.roomId, - `**All parts shipments delivered** for WO-${woNum}.` - ); + await postFedExScNote(row.workOrderId, buildAllDeliveredNoteText(woNum)); } catch (err) { - logger('shipmentTracking', `All-delivered post failed for WO ${row.workOrderId}: ${err.message}`, 'warn'); + logger('shipmentTracking', `All-delivered SC note failed for WO ${row.workOrderId}: ${err.message}`, 'warn'); } } } - return { updated: statusChanged, delivered, pendingPost }; + return { + updated: statusChanged && (notePosted || suppressRoomPosts), + delivered, + pendingPost, + }; } -async function flushConsolidatedRoomPosts(db, webex, postsByRoom) { - const sender = getSender(webex); - - for (const [roomId, entries] of postsByRoom) { +async function flushConsolidatedScNotes(db, postsByRoom) { + for (const [, entries] of postsByRoom) { if (!entries.length) continue; + const workOrderId = entries[0].workOrderId; const woNum = entries[0].woNum; - const lines = entries.map((entry) => { - const url = fedExTrackUrl(entry.trackingNumber); - let line = `- **[${entry.trackingNumber}](${url})**: ${entry.statusLabel}`; - if (entry.delivered) line += ' · Delivered'; - return line; - }); + const noteText = buildFedExConsolidatedNoteText(entries); - const md = `**FedEx update** · WO-${woNum}\n\n${lines.join('\n')}`; try { - await sender.sendMarkdown(roomId, md); + await postFedExScNote(workOrderId, noteText); + for (const entry of entries) { + await dbUpdateLastPostedStatus(db, entry.trackingNumber, workOrderId, entry.comparisonKey); + } logger( 'shipmentTracking', - `Posted consolidated update for WO ${woNum} (${entries.length} shipment(s))` + `Posted consolidated SC note for WO ${woNum} (${entries.length} shipment(s))` ); } catch (err) { - logger('shipmentTracking', `Consolidated post failed for WO ${woNum}: ${err.message}`, 'warn'); + logger('shipmentTracking', `Consolidated SC note failed for WO ${woNum}: ${err.message}`, 'warn'); + continue; } - const remaining = await dbCountActiveForWo(db, entries[0].workOrderId); + const remaining = await dbCountActiveForWo(db, workOrderId); if (remaining === 0) { try { - await sender.sendMarkdown(roomId, `**All parts shipments delivered** for WO-${woNum}.`); + await postFedExScNote(workOrderId, buildAllDeliveredNoteText(woNum)); } catch (err) { - logger('shipmentTracking', `All-delivered post failed for WO ${woNum}: ${err.message}`, 'warn'); + logger('shipmentTracking', `All-delivered SC note failed for WO ${woNum}: ${err.message}`, 'warn'); } } } @@ -597,7 +661,7 @@ export async function pollActiveShipments({ } if (consolidateRoomPosts && consolidatedPosts.size) { - await flushConsolidatedRoomPosts(db, webex, consolidatedPosts); + await flushConsolidatedScNotes(db, consolidatedPosts); } logger(