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 <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-08-27 08:21:30 -04:00
parent 0fc3eb38fa
commit abd6423218
5 changed files with 163 additions and 102 deletions

View file

@ -107,6 +107,7 @@ SC_WEBHOOK_AUTH_MODE=off # off | log | enforce
# FEDEX_ACCOUNT_NUMBER=your-fedex-account-number # FEDEX_ACCOUNT_NUMBER=your-fedex-account-number
# FEDEX_API_BASE=https://apis.fedex.com # FEDEX_API_BASE=https://apis.fedex.com
# SHIPMENT_TRACKING_ENABLED=true # SHIPMENT_TRACKING_ENABLED=true
# Status updates are written to ServiceChannel WO notes (Note Added webhook posts to the WO space).
# Every 2 hours 8am6pm Eastern (override cron/timezone if needed) # Every 2 hours 8am6pm Eastern (override cron/timezone if needed)
# SHIPMENT_TRACKING_CRON=0 0 8,10,12,14,16,18 * * * # SHIPMENT_TRACKING_CRON=0 0 8,10,12,14,16,18 * * *
# SHIPMENT_TRACKING_TIMEZONE=America/New_York # SHIPMENT_TRACKING_TIMEZONE=America/New_York

View file

@ -141,22 +141,8 @@ db.serialize(() => {
} }
} }
); );
// Backfill (Option B) previously set lastPostedStatus on in-transit rows without // Removed recurring lastPostedStatus reset on startup — it forced duplicate FedEx
// posting to WO rooms, which blocked cron updates. Clear those so the next poll posts. // posts after every container restart for backfill in-transit shipments.
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)`);
}
}
);
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS bot_message_dedup ( CREATE TABLE IF NOT EXISTS bot_message_dedup (
messageId TEXT NOT NULL, messageId TEXT NOT NULL,

View file

@ -13,9 +13,9 @@ export async function handleHelp(bot, trigger) {
text += `- **/confirmed** — confirm WO resolution (SC CONFIRMED if allowed, else note + ops)\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`; 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 += `- **/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, 8am6pm ET)\n`; text += `- **/trackStatus** — refresh FedEx shipment tracking for this WO (auto-poll every 2h, 8am6pm ET; updates are added as SC notes)\n`;
text += `- **/trackTest** — one-off FedEx API lookup (not stored; for testing)\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 { } else {
text += `- **/createWO** — create a new ServiceChannel work order (Adaptive Card form)\n`; text += `- **/createWO** — create a new ServiceChannel work order (Adaptive Card form)\n`;
text += `- **/woSummary <WO-number>** — status of any work order\n`; text += `- **/woSummary <WO-number>** — status of any work order\n`;

View file

@ -48,12 +48,22 @@ export async function handleWoTrackStatus(bot, trigger) {
return; return;
} }
const lines = (result.shipments || []).map((s) => formatShipmentStatusMarkdown(s));
const summary = const summary =
result.checked === 1 result.checked === 1
? '1 shipment checked' ? '1 shipment checked'
: `${result.checked} shipments 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({ await bot.say({
markdown: markdown:
`**FedEx tracking refresh**\n\n` + `**FedEx tracking refresh**\n\n` +

View file

@ -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 { logger } from '../utils/logger.js';
import pLimit from 'p-limit'; import pLimit from 'p-limit';
import { fetchWithRetry } from '../integrations/serviceChannel/client.js'; import { fetchWithRetry, addWorkOrderNote } from '../integrations/serviceChannel/client.js';
import { import {
isFedExConfigured, isFedExConfigured,
isShipmentTrackingEnabled, isShipmentTrackingEnabled,
@ -21,9 +21,47 @@ const BATCH_SIZE = 30;
const BACKFILL_CONCURRENCY = 2; const BACKFILL_CONCURRENCY = 2;
const FEDEX_TRACKING_RE = /\bFEDEX\s+([\d,\s]+)/gi; const FEDEX_TRACKING_RE = /\bFEDEX\s+([\d,\s]+)/gi;
const FEDEX_NUMBER_RE = /^\d{12,22}$/; const FEDEX_NUMBER_RE = /^\d{12,22}$/;
const FEDEX_NOTE_PREFIX = 'ServChan FedEx update:';
let bootLogged = false; 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) { export function parseFedExTrackingNumbers(noteText) {
if (!noteText) return []; if (!noteText) return [];
@ -59,12 +97,33 @@ function formatStatusLabelFromResult(result) {
return formatFedExStatusLine(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) { function hasShipmentStatusChanged(row, result) {
const newKey = buildStatusComparisonKey(result);
const newLabel = formatStatusLabelFromResult(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 (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; return newLabel !== row.lastPostedStatus;
} }
@ -159,6 +218,7 @@ async function applyBackfillTrackResult(db, row, result) {
} }
const statusLabel = formatStatusLabelFromResult(result); const statusLabel = formatStatusLabelFromResult(result);
const comparisonKey = buildStatusComparisonKey(result);
const delivered = isDelivered(result.statusCode, result.statusDescription); const delivered = isDelivered(result.statusCode, result.statusDescription);
const deliveredAt = delivered ? (result.deliveredAt || now) : null; const deliveredAt = delivered ? (result.deliveredAt || now) : null;
@ -168,10 +228,8 @@ async function applyBackfillTrackResult(db, row, result) {
estimatedDelivery: result.estimatedDelivery, estimatedDelivery: result.estimatedDelivery,
deliveredAt, deliveredAt,
lastCheckedAt: now, lastCheckedAt: now,
// Only mark as "already posted" for delivered packages (Option B — suppress // Delivered: mark posted so cron won't re-notify. In-transit stays NULL for first cron post.
// ops spam). In-transit rows keep lastPostedStatus NULL so the next cron lastPostedStatus: delivered ? comparisonKey : null,
// posts the current status once to the WO room.
lastPostedStatus: delivered ? statusLabel : null,
}); });
if (delivered) { 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) { async function resolveSpaceHeaderLine(roomId, workOrderId) {
try { try {
const botClientMod = await import('../integrations/webex/botClient.js'); 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 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); const sender = getSender(webex);
try { try {
@ -359,7 +430,7 @@ export async function registerShipmentsFromNote({
const md = const md =
`**FedEx shipment tracking registered** for ${woLabel}\n\n` + `**FedEx shipment tracking registered** for ${woLabel}\n\n` +
`${lines.join('\n')}\n\n` + `${lines.join('\n')}\n\n` +
`ServChan will post status updates every 2 hours (8am6pm Eastern) while in transit.`; `ServChan will add status updates to ServiceChannel notes every 2 hours (8am6pm Eastern) while in transit.`;
try { try {
await sender.sendMarkdown(roomId, md); await sender.sendMarkdown(roomId, md);
@ -372,7 +443,7 @@ export async function registerShipmentsFromNote({
`Registered ${newlyRegistered.length} tracking number(s) for WO ${workOrderId}: ${newlyRegistered.join(', ')}` `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. // waiting for the next cron window.
try { try {
const rows = []; const rows = [];
@ -418,64 +489,60 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId, { suppre
} }
const statusLabel = formatStatusLabel(result); const statusLabel = formatStatusLabel(result);
const comparisonKey = buildStatusComparisonKey(result);
const delivered = isDelivered(result.statusCode, result.statusDescription); const delivered = isDelivered(result.statusCode, result.statusDescription);
const deliveredAt = delivered ? (result.deliveredAt || now) : null; const deliveredAt = delivered ? (result.deliveredAt || now) : null;
const statusChanged = hasShipmentStatusChanged(row, result); const statusChanged = hasShipmentStatusChanged(row, result);
const newlyDelivered = delivered && !row.deliveredAt; 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, { await dbUpdateShipment(db, row.trackingNumber, row.workOrderId, {
statusCode: result.statusCode, statusCode: result.statusCode,
statusDescription: result.statusDescription, statusDescription: result.statusDescription,
estimatedDelivery: result.estimatedDelivery, estimatedDelivery: result.estimatedDelivery,
deliveredAt, deliveredAt,
lastCheckedAt: now, 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) { if (newlyDelivered) {
const opsNotified = await notifyOpsOnDelivery(webex, row); const opsNotified = await notifyOpsOnDelivery(webex, row);
if (opsNotified) { 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); const remaining = await dbCountActiveForWo(db, row.workOrderId);
if (remaining === 0 && !suppressRoomPosts) { if (remaining === 0) {
const sender = getSender(webex);
try { try {
await sender.sendMarkdown( await postFedExScNote(row.workOrderId, buildAllDeliveredNoteText(woNum));
row.roomId,
`**All parts shipments delivered** for WO-${woNum}.`
);
} catch (err) { } 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) { async function flushConsolidatedScNotes(db, postsByRoom) {
const sender = getSender(webex); for (const [, entries] of postsByRoom) {
for (const [roomId, entries] of postsByRoom) {
if (!entries.length) continue; if (!entries.length) continue;
const workOrderId = entries[0].workOrderId;
const woNum = entries[0].woNum; const woNum = entries[0].woNum;
const lines = entries.map((entry) => { const noteText = buildFedExConsolidatedNoteText(entries);
const url = fedExTrackUrl(entry.trackingNumber);
let line = `- **[${entry.trackingNumber}](${url})**: ${entry.statusLabel}`;
if (entry.delivered) line += ' · Delivered';
return line;
});
const md = `**FedEx update** · WO-${woNum}\n\n${lines.join('\n')}`;
try { try {
await sender.sendMarkdown(roomId, md); await postFedExScNote(workOrderId, noteText);
for (const entry of entries) {
await dbUpdateLastPostedStatus(db, entry.trackingNumber, workOrderId, entry.comparisonKey);
}
logger( logger(
'shipmentTracking', 'shipmentTracking',
`Posted consolidated update for WO ${woNum} (${entries.length} shipment(s))` `Posted consolidated SC note for WO ${woNum} (${entries.length} shipment(s))`
); );
} catch (err) { } 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) { if (remaining === 0) {
try { try {
await sender.sendMarkdown(roomId, `**All parts shipments delivered** for WO-${woNum}.`); await postFedExScNote(workOrderId, buildAllDeliveredNoteText(woNum));
} catch (err) { } 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) { if (consolidateRoomPosts && consolidatedPosts.size) {
await flushConsolidatedRoomPosts(db, webex, consolidatedPosts); await flushConsolidatedScNotes(db, consolidatedPosts);
} }
logger( logger(