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:
parent
0fc3eb38fa
commit
abd6423218
5 changed files with 163 additions and 102 deletions
|
|
@ -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
|
||||
|
|
|
|||
18
index.js
18
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,
|
||||
|
|
|
|||
|
|
@ -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 <WO-number>** — status of any work order\n`;
|
||||
|
|
|
|||
|
|
@ -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` +
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
try {
|
||||
await sender.sendMarkdown(
|
||||
row.roomId,
|
||||
`**All parts shipments delivered** for WO-${woNum}.`
|
||||
);
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `All-delivered post failed for WO ${row.workOrderId}: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { updated: statusChanged, delivered, pendingPost };
|
||||
}
|
||||
|
||||
async function flushConsolidatedRoomPosts(db, webex, postsByRoom) {
|
||||
const sender = getSender(webex);
|
||||
|
||||
for (const [roomId, entries] of postsByRoom) {
|
||||
if (!entries.length) continue;
|
||||
|
||||
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 md = `**FedEx update** · WO-${woNum}\n\n${lines.join('\n')}`;
|
||||
try {
|
||||
await sender.sendMarkdown(roomId, md);
|
||||
logger(
|
||||
'shipmentTracking',
|
||||
`Posted consolidated update for WO ${woNum} (${entries.length} shipment(s))`
|
||||
);
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Consolidated post failed for WO ${woNum}: ${err.message}`, 'warn');
|
||||
}
|
||||
|
||||
const remaining = await dbCountActiveForWo(db, entries[0].workOrderId);
|
||||
if (remaining === 0) {
|
||||
try {
|
||||
await sender.sendMarkdown(roomId, `**All parts shipments delivered** for WO-${woNum}.`);
|
||||
await postFedExScNote(row.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 ${row.workOrderId}: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updated: statusChanged && (notePosted || suppressRoomPosts),
|
||||
delivered,
|
||||
pendingPost,
|
||||
};
|
||||
}
|
||||
|
||||
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 noteText = buildFedExConsolidatedNoteText(entries);
|
||||
|
||||
try {
|
||||
await postFedExScNote(workOrderId, noteText);
|
||||
for (const entry of entries) {
|
||||
await dbUpdateLastPostedStatus(db, entry.trackingNumber, workOrderId, entry.comparisonKey);
|
||||
}
|
||||
logger(
|
||||
'shipmentTracking',
|
||||
`Posted consolidated SC note for WO ${woNum} (${entries.length} shipment(s))`
|
||||
);
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Consolidated SC note failed for WO ${woNum}: ${err.message}`, 'warn');
|
||||
continue;
|
||||
}
|
||||
|
||||
const remaining = await dbCountActiveForWo(db, workOrderId);
|
||||
if (remaining === 0) {
|
||||
try {
|
||||
await postFedExScNote(workOrderId, buildAllDeliveredNoteText(woNum));
|
||||
} catch (err) {
|
||||
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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue