Add FedEx shipment tracking with polling, ops delivery alerts, and backfill.
Auto-register tracking numbers from SC notes, poll FedEx on a business-hours cron, and expose /trackStatus, /trackTest, and admin /track-backfill. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cba047cb4e
commit
d6549c07f8
12 changed files with 1186 additions and 4 deletions
17
.env.example
17
.env.example
|
|
@ -50,7 +50,7 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport
|
|||
# Cron for daily consolidated digest (default 14:00 UTC). Digest only — no auto-delete.
|
||||
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *
|
||||
|
||||
# --- Admin endpoints (/cleanup-test, /stale-workorders) ---
|
||||
# --- Admin endpoints (/cleanup-test, /stale-workorders, /track-backfill) ---
|
||||
# Required in production. If unset in NODE_ENV=production the endpoints refuse
|
||||
# requests with 503. In dev (NODE_ENV!=production) unset means "allow" with a
|
||||
# warning in the log.
|
||||
|
|
@ -96,6 +96,21 @@ SC_WEBHOOK_AUTH_MODE=off # off | log | enforce
|
|||
# If unset, ServChan picks the first reason matching "revised"/"superseded"/etc.
|
||||
# SC_PROPOSAL_REJECT_REASON_ID=7
|
||||
|
||||
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *
|
||||
|
||||
# --- FedEx shipment tracking (optional) ---
|
||||
# Register at https://developer.fedex.com → create project → add Track API v1.
|
||||
# Sandbox: FEDEX_API_BASE=https://apis-sandbox.fedex.com
|
||||
# Production: FEDEX_API_BASE=https://apis.fedex.com
|
||||
# FEDEX_CLIENT_ID=your-api-key
|
||||
# FEDEX_CLIENT_SECRET=your-secret-key
|
||||
# FEDEX_ACCOUNT_NUMBER=your-fedex-account-number
|
||||
# FEDEX_API_BASE=https://apis.fedex.com
|
||||
# SHIPMENT_TRACKING_ENABLED=true
|
||||
# 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
|
||||
|
||||
# --- Attachment auto-post (optional) ---
|
||||
# When true (default), ServChan posts SC photos/invoices to the Webex WO room
|
||||
# on room creation and when WorkOrderNoteAdded webhooks include AttachmentIds.
|
||||
|
|
|
|||
36
index.js
36
index.js
|
|
@ -10,6 +10,7 @@ import { createApp, setupCron } from './src/server/app.js';
|
|||
import { summarizeTicketDescription } from './src/integrations/xai/client.js';
|
||||
import { WebexService } from './src/services/webexService.js';
|
||||
import { getStaleWorkOrdersReport } from './src/services/staleWorkOrderReportService.js';
|
||||
import { logTrackingBootStatus } from './src/services/shipmentTrackingService.js';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// CONFIGURATION
|
||||
|
|
@ -109,6 +110,37 @@ db.serialize(() => {
|
|||
noteText TEXT
|
||||
)
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS shipment_tracking (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workOrderId INTEGER NOT NULL,
|
||||
roomId TEXT NOT NULL,
|
||||
carrier TEXT NOT NULL DEFAULT 'FEDEX',
|
||||
trackingNumber TEXT NOT NULL,
|
||||
statusCode TEXT,
|
||||
statusDescription TEXT,
|
||||
estimatedDelivery TEXT,
|
||||
deliveredAt TEXT,
|
||||
lastCheckedAt TEXT,
|
||||
lastPostedStatus TEXT,
|
||||
detectedAt TEXT NOT NULL,
|
||||
sourceNote TEXT,
|
||||
opsDeliveredNotifiedAt TEXT,
|
||||
UNIQUE(workOrderId, trackingNumber)
|
||||
)
|
||||
`);
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipment_tracking_active
|
||||
ON shipment_tracking(deliveredAt)
|
||||
`);
|
||||
db.run(
|
||||
`ALTER TABLE shipment_tracking ADD COLUMN opsDeliveredNotifiedAt TEXT`,
|
||||
(err) => {
|
||||
if (err && !/duplicate column name/i.test(err.message)) {
|
||||
console.warn(`[DB] shipment_tracking migration: ${err.message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(`[DB] Connected to ${DB_PATH}`);
|
||||
});
|
||||
|
||||
|
|
@ -155,7 +187,9 @@ const Framework = initializeBot({
|
|||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Cron + Express App (extracted)
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
setupCron({ db, runSpaceCleanup });
|
||||
setupCron({ db, runSpaceCleanup, webex: webexService });
|
||||
|
||||
logTrackingBootStatus();
|
||||
|
||||
const app = createApp({
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { handleAvStatus } from '../commands/avStatus.js';
|
|||
import { handleWoApprove } from '../commands/woApprove.js';
|
||||
import { handleWoConfirmed } from '../commands/woConfirmed.js';
|
||||
import { handleWoAddNote } from '../commands/woAddNote.js';
|
||||
import { handleWoTrackStatus } from '../commands/woTrackStatus.js';
|
||||
import { handleWoTrackTest } from '../commands/woTrackTest.js';
|
||||
import approvalService from '../services/approvalService.js';
|
||||
import invoiceApprovalService from '../services/invoiceApprovalService.js';
|
||||
import { installMercuryGuard } from './mercuryGuard.js';
|
||||
|
|
@ -139,6 +141,12 @@ export function initializeBot({ webexConfig }) {
|
|||
case 'addnote':
|
||||
await handleWoAddNote(bot, trigger);
|
||||
break;
|
||||
case 'trackstatus':
|
||||
await handleWoTrackStatus(bot, trigger);
|
||||
break;
|
||||
case 'tracktest':
|
||||
await handleWoTrackTest(bot, trigger);
|
||||
break;
|
||||
default:
|
||||
await handleUnknown(bot, trigger);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -12,12 +12,16 @@ export async function handleHelp(bot, trigger) {
|
|||
text += `- **/woApprove** — (re)post proposal approval card for current WO (when WAITING FOR APPROVAL)\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 += `- **/trackStatus** — refresh FedEx shipment tracking for this WO (auto-poll every 2h, 8am–6pm ET)\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`;
|
||||
} else {
|
||||
text += `- **/woSummary <WO-number>** — status of any work order\n`;
|
||||
text += `- **/woHistory <store-number>** — History of AV issues.\n`;
|
||||
text += `- **/woAttachments <WO-number>** — download attachments for any work order\n`;
|
||||
text += `- **/avStatus <store-number>** — AV device status for any store\n`;
|
||||
text += `- **/woApprove <WO-number>** — post proposal approval card (manual)\n`;
|
||||
text += `- **/trackTest <tracking-number>** — one-off FedEx API lookup (not stored)\n`;
|
||||
}
|
||||
|
||||
text += `\nType **/help** again in a 1:1 or group space for context-specific commands.`;
|
||||
|
|
|
|||
61
src/commands/woTrackStatus.js
Normal file
61
src/commands/woTrackStatus.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// src/commands/woTrackStatus.js
|
||||
import botClient from '../integrations/webex/botClient.js';
|
||||
import db from '../db/mappings.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { resolveWoRoomContext } from '../utils/woRoomContext.js';
|
||||
import {
|
||||
isFedExConfigured,
|
||||
isShipmentTrackingEnabled,
|
||||
} from '../integrations/fedex/client.js';
|
||||
import {
|
||||
pollShipmentsForWorkOrder,
|
||||
formatShipmentStatusMarkdown,
|
||||
} from '../services/shipmentTrackingService.js';
|
||||
import webexService from '../services/webexService.js';
|
||||
|
||||
export async function handleWoTrackStatus(bot, trigger) {
|
||||
logger('wo:trackStatus', 'HANDLER ENTERED');
|
||||
|
||||
if (!isShipmentTrackingEnabled() || !isFedExConfigured()) {
|
||||
await bot.say('FedEx shipment tracking is not configured on this bot.');
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = trigger.message?.roomId;
|
||||
const isGroup = trigger.message?.roomType === 'group';
|
||||
|
||||
const ctx = await resolveWoRoomContext({ db, botClient, roomId, isGroup });
|
||||
if (!ctx.ok) {
|
||||
await bot.say(ctx.error);
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.say('Checking FedEx tracking status for this work order…');
|
||||
|
||||
const result = await pollShipmentsForWorkOrder({
|
||||
db,
|
||||
webex: webexService,
|
||||
workOrderId: ctx.workOrderId,
|
||||
});
|
||||
|
||||
if (result.skipped) {
|
||||
await bot.say('Tracking poll could not run.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.checked === 0) {
|
||||
await bot.say('No active FedEx shipments are registered for this work order.');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = (result.shipments || []).map((s) => formatShipmentStatusMarkdown(s));
|
||||
const summary =
|
||||
`${result.checked} checked · ${result.updated} status update(s) posted · ${result.delivered} newly delivered`;
|
||||
|
||||
await bot.say({
|
||||
markdown:
|
||||
`**FedEx tracking refresh**\n\n` +
|
||||
`${lines.join('\n')}\n\n` +
|
||||
`_${summary}_`,
|
||||
});
|
||||
}
|
||||
83
src/commands/woTrackTest.js
Normal file
83
src/commands/woTrackTest.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// src/commands/woTrackTest.js
|
||||
// One-off FedEx API lookup — does not persist to shipment_tracking.
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { isFedExConfigured, trackByNumbers } from '../integrations/fedex/client.js';
|
||||
import { parseFedExTrackingNumbers } from '../services/shipmentTrackingService.js';
|
||||
|
||||
const PLAIN_NUMBER_RE = /^\d{12,22}$/;
|
||||
|
||||
function parseTrackingArgs(args = []) {
|
||||
const raw = args.join(' ').trim();
|
||||
if (!raw) return [];
|
||||
|
||||
const fromFedExPrefix = parseFedExTrackingNumbers(`FEDEX ${raw}`);
|
||||
if (fromFedExPrefix.length) return fromFedExPrefix;
|
||||
|
||||
const found = new Set();
|
||||
for (const part of raw.split(/[,\s]+/)) {
|
||||
const num = part.trim().replace(/^fedex\s*/i, '');
|
||||
if (PLAIN_NUMBER_RE.test(num)) found.add(num);
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
function formatResult(result) {
|
||||
const url = `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(result.trackingNumber)}`;
|
||||
if (!result.ok) {
|
||||
return `- **[${result.trackingNumber}](${url})** — API error: ${result.error}`;
|
||||
}
|
||||
|
||||
let line = `- **[${result.trackingNumber}](${url})**`;
|
||||
if (result.statusDescription) line += ` — ${result.statusDescription}`;
|
||||
else if (result.statusCode) line += ` — ${result.statusCode}`;
|
||||
if (result.estimatedDelivery) {
|
||||
line += ` · ETA ${new Date(result.estimatedDelivery).toLocaleDateString('en-US')}`;
|
||||
}
|
||||
if (result.deliveredAt) {
|
||||
line += ` · Delivered ${new Date(result.deliveredAt).toLocaleDateString('en-US')}`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
export async function handleWoTrackTest(bot, trigger) {
|
||||
logger('wo:trackTest', 'HANDLER ENTERED');
|
||||
|
||||
if (!isFedExConfigured()) {
|
||||
await bot.say('FedEx API is not configured (`FEDEX_CLIENT_ID`, `FEDEX_CLIENT_SECRET`, `FEDEX_API_BASE`).');
|
||||
return;
|
||||
}
|
||||
|
||||
const numbers = parseTrackingArgs(trigger.args);
|
||||
if (!numbers.length) {
|
||||
await bot.say(
|
||||
'Usage: `/trackTest 876239420601` or `/trackTest FEDEX 876239420601, 876239420612`\n\n' +
|
||||
'Looks up status via the FedEx API only — nothing is saved to ServChan.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (numbers.length > 30) {
|
||||
await bot.say('FedEx allows up to 30 tracking numbers per request. Please pass fewer numbers.');
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.say(`Querying FedEx for **${numbers.length}** tracking number(s)…`);
|
||||
|
||||
try {
|
||||
const results = await trackByNumbers(numbers);
|
||||
const lines = numbers.map((n) => formatResult(results.get(n) || { trackingNumber: n, ok: false, error: 'No response' }));
|
||||
const apiBase = process.env.FEDEX_API_BASE || 'https://apis.fedex.com';
|
||||
|
||||
await bot.say({
|
||||
markdown:
|
||||
`**FedEx API test** (\`${apiBase}\`)\n\n` +
|
||||
`${lines.join('\n')}\n\n` +
|
||||
`_Not stored — use SC notes or /trackStatus for ongoing WO tracking._`,
|
||||
});
|
||||
logger('wo:trackTest', `Looked up ${numbers.length} number(s): ${numbers.join(', ')}`);
|
||||
} catch (err) {
|
||||
logger('wo:trackTest', `FedEx API error: ${err.message}`, 'error');
|
||||
await bot.say(`FedEx API request failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,13 @@ export function loadSecrets() {
|
|||
optisign: {
|
||||
apiKey: optionalEnv('OPTISIGN_API_KEY'),
|
||||
},
|
||||
|
||||
fedex: {
|
||||
clientId: optionalEnv('FEDEX_CLIENT_ID'),
|
||||
clientSecret: optionalEnv('FEDEX_CLIENT_SECRET'),
|
||||
accountNumber: optionalEnv('FEDEX_ACCOUNT_NUMBER'),
|
||||
apiBase: optionalEnv('FEDEX_API_BASE') || 'https://apis.fedex.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
199
src/integrations/fedex/client.js
Normal file
199
src/integrations/fedex/client.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* FedEx Track API client (OAuth 2.0 + track/v1/trackingnumbers).
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import qs from 'querystring';
|
||||
import { loadSecrets } from '../../config/secrets.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
const secrets = loadSecrets();
|
||||
const mutex = new Mutex();
|
||||
|
||||
let cachedToken = null;
|
||||
let tokenExpiresAt = 0;
|
||||
|
||||
function getApiBase() {
|
||||
return (process.env.FEDEX_API_BASE || secrets.fedex?.apiBase || 'https://apis.fedex.com').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function isFedExConfigured() {
|
||||
const clientId = process.env.FEDEX_CLIENT_ID || secrets.fedex?.clientId;
|
||||
const clientSecret = process.env.FEDEX_CLIENT_SECRET || secrets.fedex?.clientSecret;
|
||||
return Boolean(clientId && clientSecret);
|
||||
}
|
||||
|
||||
export function isShipmentTrackingEnabled() {
|
||||
const raw = process.env.SHIPMENT_TRACKING_ENABLED;
|
||||
if (raw == null || raw === '') return isFedExConfigured();
|
||||
return !/^(0|false|no|off)$/i.test(String(raw).trim());
|
||||
}
|
||||
|
||||
async function getFedExToken(forceRefresh = false) {
|
||||
const clientId = process.env.FEDEX_CLIENT_ID || secrets.fedex?.clientId;
|
||||
const clientSecret = process.env.FEDEX_CLIENT_SECRET || secrets.fedex?.clientSecret;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('FedEx credentials not configured (FEDEX_CLIENT_ID, FEDEX_CLIENT_SECRET)');
|
||||
}
|
||||
|
||||
const release = await mutex.acquire();
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && cachedToken && now < tokenExpiresAt) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
const base = getApiBase();
|
||||
const response = await axios.post(
|
||||
`${base}/oauth/token`,
|
||||
qs.stringify({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
timeout: 15000,
|
||||
}
|
||||
);
|
||||
|
||||
const { access_token, expires_in } = response.data;
|
||||
cachedToken = access_token;
|
||||
tokenExpiresAt = now + (Number(expires_in) || 3600) * 1000 - 300_000;
|
||||
logger('fedex:oauth', 'New FedEx access token acquired');
|
||||
return access_token;
|
||||
} catch (err) {
|
||||
const msg = err.response?.data
|
||||
? JSON.stringify(err.response.data)
|
||||
: err.message;
|
||||
logger('fedex:oauth', `Token fetch failed: ${msg}`, 'error');
|
||||
throw new Error(`FedEx OAuth failed: ${msg}`);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
function pickLatestStatus(trackResult) {
|
||||
const detail = trackResult?.latestStatusDetail || {};
|
||||
const scanEvents = trackResult?.scanEvents || [];
|
||||
const latestScan = scanEvents.length > 0 ? scanEvents[0] : null;
|
||||
|
||||
const statusCode = detail.code || detail.derivedCode || latestScan?.eventType || '';
|
||||
const statusDescription =
|
||||
detail.description ||
|
||||
detail.statusByLocale ||
|
||||
latestScan?.eventDescription ||
|
||||
latestScan?.derivedStatus ||
|
||||
'';
|
||||
|
||||
let estimatedDelivery = null;
|
||||
const dateTimes = trackResult?.dateAndTimes || trackResult?.estimatedDeliveryTimeWindow || [];
|
||||
if (Array.isArray(dateTimes)) {
|
||||
const est = dateTimes.find((d) =>
|
||||
/ESTIMATED_DELIVERY|ANTICIPATED|TENDER/i.test(String(d.type || d.dateTimeType || ''))
|
||||
);
|
||||
if (est?.dateTime) estimatedDelivery = est.dateTime;
|
||||
}
|
||||
if (!estimatedDelivery && trackResult?.standardTransitTimeWindow?.window?.ends) {
|
||||
estimatedDelivery = trackResult.standardTransitTimeWindow.window.ends;
|
||||
}
|
||||
|
||||
let deliveredAt = null;
|
||||
if (String(statusCode).toUpperCase() === 'DL' || /delivered/i.test(statusDescription)) {
|
||||
const deliveredEvent = scanEvents.find((e) =>
|
||||
String(e.eventType || '').toUpperCase() === 'DL' || /delivered/i.test(e.eventDescription || '')
|
||||
);
|
||||
deliveredAt = deliveredEvent?.date || detail.ancillaryDetails?.[0]?.value || new Date().toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: String(statusCode || '').trim(),
|
||||
statusDescription: String(statusDescription || '').trim(),
|
||||
estimatedDelivery,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTrackResult(trackingNumber, completeTrackResult) {
|
||||
const trackResults = completeTrackResult?.trackResults || [];
|
||||
const trackResult = trackResults[0] || null;
|
||||
|
||||
if (!trackResult) {
|
||||
const error = completeTrackResult?.error || completeTrackResult?.alerts?.[0];
|
||||
return {
|
||||
trackingNumber,
|
||||
ok: false,
|
||||
error: error?.message || error?.code || 'No track results returned',
|
||||
};
|
||||
}
|
||||
|
||||
const status = pickLatestStatus(trackResult);
|
||||
return {
|
||||
trackingNumber,
|
||||
ok: true,
|
||||
...status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track up to 30 FedEx tracking numbers in one API call.
|
||||
* @returns {Promise<Map<string, object>>} trackingNumber → result
|
||||
*/
|
||||
export async function trackByNumbers(trackingNumbers = [], { _retried = false } = {}) {
|
||||
const nums = [...new Set(trackingNumbers.map((n) => String(n).trim()).filter(Boolean))];
|
||||
if (!nums.length) return new Map();
|
||||
|
||||
const token = await getFedExToken(_retried);
|
||||
const base = getApiBase();
|
||||
const accountNumber = process.env.FEDEX_ACCOUNT_NUMBER || secrets.fedex?.accountNumber || null;
|
||||
|
||||
const body = {
|
||||
includeDetailedScans: true,
|
||||
trackingInfo: nums.map((trackingNumber) => ({
|
||||
trackingNumberInfo: { trackingNumber },
|
||||
})),
|
||||
};
|
||||
|
||||
if (accountNumber) {
|
||||
body.shipperAccountNumber = { value: String(accountNumber) };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${base}/track/v1/trackingnumbers`, body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-locale': 'en_US',
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
const results = new Map();
|
||||
const complete = response.data?.output?.completeTrackResults || [];
|
||||
|
||||
for (const entry of complete) {
|
||||
const num = entry?.trackingNumber || entry?.trackResults?.[0]?.trackingNumberInfo?.trackingNumber;
|
||||
if (!num) continue;
|
||||
results.set(String(num), normalizeTrackResult(String(num), entry));
|
||||
}
|
||||
|
||||
for (const num of nums) {
|
||||
if (!results.has(num)) {
|
||||
results.set(num, { trackingNumber: num, ok: false, error: 'Not returned by FedEx API' });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401 && !_retried) {
|
||||
await getFedExToken(true);
|
||||
return trackByNumbers(nums, { _retried: true });
|
||||
}
|
||||
const msg = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
||||
logger('fedex:track', `Track request failed: ${msg}`, 'error');
|
||||
throw new Error(`FedEx track failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default { isFedExConfigured, isShipmentTrackingEnabled, trackByNumbers };
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* src/server/adminAuth.js
|
||||
*
|
||||
* Small, dependency-free HTTP auth middleware for admin/report endpoints
|
||||
* (/cleanup-test, /stale-workorders, etc.).
|
||||
* (/cleanup-test, /stale-workorders, /track-backfill, etc.).
|
||||
*
|
||||
* Accepts either:
|
||||
* 1. `Authorization: Bearer <ADMIN_TOKEN>` header, or
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import cron from 'node-cron';
|
|||
import { logger, ensureLogDir, getLogDir } from '../utils/logger.js';
|
||||
import { verifyWebhook } from './webhookAuth.js';
|
||||
import { requireAdmin } from './adminAuth.js';
|
||||
import { pollActiveShipments, backfillShipmentsFromAllMappings } from '../services/shipmentTrackingService.js';
|
||||
import { isShipmentTrackingEnabled } from '../integrations/fedex/client.js';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// HTML escaping — used everywhere we interpolate SC-sourced data into HTML
|
||||
|
|
@ -249,6 +251,91 @@ export function createApp({
|
|||
res.send(html);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// /track-backfill — protected admin endpoint
|
||||
//
|
||||
// Dry-run by default. Scans all mapped WOs for FedEx numbers in SC notes,
|
||||
// registers them silently (no WO-room post), then polls FedEx once in live
|
||||
// mode. Already-delivered packages are marked without ops-room notification.
|
||||
// Requires ?dryRun=false&live=true to write.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
app.get('/track-backfill', requireAdmin(), async (req, res) => {
|
||||
const requestedLive = req.query.dryRun === 'false' && req.query.live === 'true';
|
||||
const dryRun = !requestedLive;
|
||||
|
||||
logger('track-backfill', `Called dryRun=${dryRun} live=${requestedLive} by ${req.ip}`);
|
||||
|
||||
const result = await backfillShipmentsFromAllMappings({ db, dryRun });
|
||||
|
||||
if (result.error) {
|
||||
return res.status(503).send(`<h2>Error</h2><pre>${esc(result.error)}</pre>`);
|
||||
}
|
||||
|
||||
const { summary, results, mode } = result;
|
||||
|
||||
let html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>FedEx Track Backfill - ${esc(mode)}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 20px; font-size: 0.9em; }
|
||||
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; vertical-align: top; }
|
||||
th { background-color: #f0f0f0; }
|
||||
h1 { color: #333; }
|
||||
.banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
|
||||
.banner-live { background: #f8d7da; border: 1px solid #dc3545; }
|
||||
.banner-dry { background: #d1ecf1; border: 1px solid #0c5460; }
|
||||
.error { background-color: #f8d7da; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>FedEx Track Backfill - ${esc(mode)}</h1>
|
||||
<div class="banner ${dryRun ? 'banner-dry' : 'banner-live'}">
|
||||
<strong>${dryRun ? 'DRY RUN' : 'LIVE RUN'}</strong> —
|
||||
${dryRun
|
||||
? 'No changes were made. Add <code>?dryRun=false&live=true</code> to register and poll FedEx.'
|
||||
: 'New tracking numbers were inserted and polled once. WO rooms were not notified; historical deliveries were marked silently (no ops post).'}
|
||||
</div>
|
||||
<h2>Summary</h2>
|
||||
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
|
||||
|
||||
<h2>Work Orders With Tracking (${results.length})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Work Order ID</th>
|
||||
<th>Room ID</th>
|
||||
<th>Found</th>
|
||||
<th>Would Register / Registered</th>
|
||||
<th>Already Tracked</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const r of results) {
|
||||
html += `
|
||||
<tr class="${r.error ? 'error' : ''}">
|
||||
<td>${esc(r.workOrderId)}</td>
|
||||
<td><code>${esc(r.roomId)}</code></td>
|
||||
<td>${esc((r.trackingNumbers || []).join(', '))}</td>
|
||||
<td>${esc((r.registered || []).join(', '))}</td>
|
||||
<td>${esc((r.alreadyTracked || []).join(', '))}</td>
|
||||
<td>${esc(r.error || '')}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
res.send(html);
|
||||
});
|
||||
|
||||
// Stale Work Order Report (Phase 0) — protected admin endpoint
|
||||
if (getStaleWorkOrdersReport) {
|
||||
app.get('/stale-workorders', requireAdmin(), async (req, res) => {
|
||||
|
|
@ -428,7 +515,7 @@ async function cleanupOldLogs({ maxAgeDays = 7 } = {}) {
|
|||
* Convenience helper to set up the daily log cleanup cron.
|
||||
* Can be called from the thin bootstrap.
|
||||
*/
|
||||
export function setupCron({ db, runSpaceCleanup } = {}) {
|
||||
export function setupCron({ db, runSpaceCleanup, webex } = {}) {
|
||||
cron.schedule('0 15 0,8,16 * * *', () => {
|
||||
cleanupOldLogs().catch(err => {
|
||||
logger('cron:cleanupOldLogs', `Unhandled: ${err.message}`, 'error');
|
||||
|
|
@ -446,6 +533,22 @@ export function setupCron({ db, runSpaceCleanup } = {}) {
|
|||
});
|
||||
logger('cron', `Space cleanup consolidated digest scheduled (${reminderCron})`);
|
||||
}
|
||||
|
||||
if (db && webex && isShipmentTrackingEnabled()) {
|
||||
const trackingCron = process.env.SHIPMENT_TRACKING_CRON || '0 0 8,10,12,14,16,18 * * *';
|
||||
const trackingTz = process.env.SHIPMENT_TRACKING_TIMEZONE || 'America/New_York';
|
||||
cron.schedule(
|
||||
trackingCron,
|
||||
() => {
|
||||
logger('cron:shipmentTracking', 'Starting FedEx shipment status poll');
|
||||
pollActiveShipments({ db, webex }).catch((err) => {
|
||||
logger('cron:shipmentTracking', `Unhandled: ${err.message}`, 'error');
|
||||
});
|
||||
},
|
||||
{ timezone: trackingTz }
|
||||
);
|
||||
logger('cron', `FedEx shipment tracking poll scheduled (${trackingCron}, ${trackingTz})`);
|
||||
}
|
||||
}
|
||||
|
||||
export { cleanupOldLogs };
|
||||
|
|
|
|||
654
src/services/shipmentTrackingService.js
Normal file
654
src/services/shipmentTrackingService.js
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
/**
|
||||
* FedEx shipment tracking: parse SC notes, persist, poll, post to WO rooms.
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import pLimit from 'p-limit';
|
||||
import { fetchWithRetry } from '../integrations/serviceChannel/client.js';
|
||||
import {
|
||||
isFedExConfigured,
|
||||
isShipmentTrackingEnabled,
|
||||
trackByNumbers,
|
||||
} from '../integrations/fedex/client.js';
|
||||
import webexService from './webexService.js';
|
||||
import {
|
||||
getCompletedOperationsRoomId,
|
||||
parseServChanRoomTitle,
|
||||
} from '../utils/servchanRoomTitle.js';
|
||||
|
||||
const BATCH_SIZE = 30;
|
||||
const BACKFILL_CONCURRENCY = 2;
|
||||
const FEDEX_TRACKING_RE = /\bFEDEX\s+([\d,\s]+)/gi;
|
||||
const FEDEX_NUMBER_RE = /^\d{12,22}$/;
|
||||
|
||||
let bootLogged = false;
|
||||
|
||||
export function parseFedExTrackingNumbers(noteText) {
|
||||
if (!noteText) return [];
|
||||
|
||||
const found = new Set();
|
||||
let match;
|
||||
FEDEX_TRACKING_RE.lastIndex = 0;
|
||||
|
||||
while ((match = FEDEX_TRACKING_RE.exec(String(noteText))) !== null) {
|
||||
const chunk = match[1] || '';
|
||||
for (const part of chunk.split(/[,\s]+/)) {
|
||||
const num = part.trim();
|
||||
if (FEDEX_NUMBER_RE.test(num)) found.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
return [...found];
|
||||
}
|
||||
|
||||
function fedExTrackUrl(trackingNumber) {
|
||||
return `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}`;
|
||||
}
|
||||
|
||||
function isDelivered(statusCode, statusDescription) {
|
||||
if (String(statusCode).toUpperCase() === 'DL') return true;
|
||||
return /delivered/i.test(String(statusDescription || ''));
|
||||
}
|
||||
|
||||
function formatStatusLabel(row) {
|
||||
const parts = [];
|
||||
if (row.statusDescription) parts.push(row.statusDescription);
|
||||
else if (row.statusCode) parts.push(row.statusCode);
|
||||
else parts.push('Status unknown');
|
||||
if (row.estimatedDelivery) {
|
||||
parts.push(`ETA ${new Date(row.estimatedDelivery).toLocaleDateString('en-US')}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function formatStatusLabelFromResult(result) {
|
||||
const parts = [];
|
||||
if (result.statusDescription) parts.push(result.statusDescription);
|
||||
else if (result.statusCode) parts.push(result.statusCode);
|
||||
else parts.push('Status unknown');
|
||||
if (result.estimatedDelivery) {
|
||||
parts.push(`ETA ${new Date(result.estimatedDelivery).toLocaleDateString('en-US')}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function formatShipmentStatusMarkdown({
|
||||
trackingNumber,
|
||||
statusDescription = null,
|
||||
statusCode = null,
|
||||
estimatedDelivery = null,
|
||||
deliveredAt = null,
|
||||
ok = true,
|
||||
error = null,
|
||||
}) {
|
||||
const url = fedExTrackUrl(trackingNumber);
|
||||
if (!ok || error) {
|
||||
return `- **[${trackingNumber}](${url})** — ${error || 'Status unavailable'}`;
|
||||
}
|
||||
|
||||
let line = `- **[${trackingNumber}](${url})**`;
|
||||
if (statusDescription) line += ` — ${statusDescription}`;
|
||||
else if (statusCode) line += ` — ${statusCode}`;
|
||||
if (estimatedDelivery) {
|
||||
line += ` · ETA ${new Date(estimatedDelivery).toLocaleDateString('en-US')}`;
|
||||
}
|
||||
if (deliveredAt) {
|
||||
line += ` · Delivered ${new Date(deliveredAt).toLocaleDateString('en-US')}`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function shipmentSnapshotFromTrackResult(row, result) {
|
||||
if (!result.ok) {
|
||||
return {
|
||||
trackingNumber: row.trackingNumber,
|
||||
ok: false,
|
||||
error: result.error || 'Status unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
const delivered = isDelivered(result.statusCode, result.statusDescription);
|
||||
return {
|
||||
trackingNumber: row.trackingNumber,
|
||||
statusDescription: result.statusDescription,
|
||||
statusCode: result.statusCode,
|
||||
estimatedDelivery: result.estimatedDelivery,
|
||||
deliveredAt: delivered ? (result.deliveredAt || new Date().toISOString()) : null,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
function collectTrackingNumbersFromNotes(notes = []) {
|
||||
const found = new Set();
|
||||
for (const note of notes) {
|
||||
for (const num of parseFedExTrackingNumbers(note?.NoteData)) {
|
||||
found.add(num);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
function dbGetAllMappings(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT workOrderId, roomId FROM mappings', [], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWorkOrderNotes(workOrderId) {
|
||||
const notesRes = await fetchWithRetry(`/workorders/${workOrderId}/notes`, { timeout: 30000 });
|
||||
return notesRes.data?.Notes || [];
|
||||
}
|
||||
|
||||
async function applyBackfillTrackResult(db, row, result) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (!result.ok) {
|
||||
await dbUpdateShipment(db, row.trackingNumber, row.workOrderId, {
|
||||
lastCheckedAt: now,
|
||||
statusCode: row.statusCode,
|
||||
statusDescription: row.statusDescription,
|
||||
estimatedDelivery: row.estimatedDelivery,
|
||||
deliveredAt: row.deliveredAt,
|
||||
lastPostedStatus: row.lastPostedStatus,
|
||||
});
|
||||
return { delivered: false, inTransit: false, error: result.error };
|
||||
}
|
||||
|
||||
const statusLabel = formatStatusLabelFromResult(result);
|
||||
const delivered = isDelivered(result.statusCode, result.statusDescription);
|
||||
const deliveredAt = delivered ? (result.deliveredAt || now) : null;
|
||||
|
||||
await dbUpdateShipment(db, row.trackingNumber, row.workOrderId, {
|
||||
statusCode: result.statusCode,
|
||||
statusDescription: result.statusDescription,
|
||||
estimatedDelivery: result.estimatedDelivery,
|
||||
deliveredAt,
|
||||
lastCheckedAt: now,
|
||||
lastPostedStatus: statusLabel,
|
||||
});
|
||||
|
||||
if (delivered) {
|
||||
await dbMarkOpsDeliveredNotified(db, row.workOrderId, row.trackingNumber);
|
||||
}
|
||||
|
||||
return { delivered, inTransit: !delivered, error: null };
|
||||
}
|
||||
|
||||
function dbGetShipment(db, workOrderId, trackingNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT * FROM shipment_tracking WHERE workOrderId = ? AND trackingNumber = ?`,
|
||||
[workOrderId, trackingNumber],
|
||||
(err, row) => (err ? reject(err) : resolve(row || null))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function dbInsertShipment(db, row) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO shipment_tracking
|
||||
(workOrderId, roomId, carrier, trackingNumber, detectedAt, sourceNote)
|
||||
VALUES (?, ?, 'FEDEX', ?, ?, ?)`,
|
||||
[row.workOrderId, row.roomId, row.trackingNumber, row.detectedAt, row.sourceNote ?? null],
|
||||
function onRun(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this.changes > 0);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function dbUpdateShipment(db, trackingNumber, workOrderId, fields) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE shipment_tracking SET
|
||||
statusCode = ?,
|
||||
statusDescription = ?,
|
||||
estimatedDelivery = ?,
|
||||
deliveredAt = ?,
|
||||
lastCheckedAt = ?,
|
||||
lastPostedStatus = ?
|
||||
WHERE workOrderId = ? AND trackingNumber = ?`,
|
||||
[
|
||||
fields.statusCode ?? null,
|
||||
fields.statusDescription ?? null,
|
||||
fields.estimatedDelivery ?? null,
|
||||
fields.deliveredAt ?? null,
|
||||
fields.lastCheckedAt,
|
||||
fields.lastPostedStatus ?? null,
|
||||
workOrderId,
|
||||
trackingNumber,
|
||||
],
|
||||
(err) => (err ? reject(err) : resolve())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function dbMarkOpsDeliveredNotified(db, workOrderId, trackingNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE shipment_tracking SET opsDeliveredNotifiedAt = ? WHERE workOrderId = ? AND trackingNumber = ?`,
|
||||
[new Date().toISOString(), workOrderId, trackingNumber],
|
||||
(err) => (err ? reject(err) : resolve())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSpaceHeaderLine(roomId, workOrderId) {
|
||||
try {
|
||||
const botClientMod = await import('../integrations/webex/botClient.js');
|
||||
const room = await botClientMod.default.getRoom(roomId);
|
||||
const parsed = parseServChanRoomTitle(room?.title);
|
||||
return parsed?.headerLine || room?.title || `WO ${workOrderId}`;
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Could not resolve room title for ${roomId}: ${err.message}`, 'warn');
|
||||
return `WO ${workOrderId}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyOpsOnDelivery(webex, row) {
|
||||
if (row.opsDeliveredNotifiedAt) return false;
|
||||
|
||||
const opsRoomId = getCompletedOperationsRoomId();
|
||||
if (!opsRoomId) {
|
||||
logger('shipmentTracking', 'COMPLETED_OPERATIONS_ROOM_ID not set — skipping ops delivery notification', 'warn');
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerLine = await resolveSpaceHeaderLine(row.roomId, row.workOrderId);
|
||||
const md = `${headerLine}\nFedEx ${row.trackingNumber} — successfully delivered`;
|
||||
|
||||
const sender = getSender(webex);
|
||||
try {
|
||||
await sender.sendMarkdown(opsRoomId, md);
|
||||
logger(
|
||||
'shipmentTracking',
|
||||
`Posted delivery notification to ops for ${row.trackingNumber} (WO ${row.workOrderId})`
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Ops delivery post failed for ${row.trackingNumber}: ${err.message}`, 'warn');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dbGetActiveShipments(db, workOrderId = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = workOrderId
|
||||
? `SELECT * FROM shipment_tracking WHERE deliveredAt IS NULL AND workOrderId = ? ORDER BY detectedAt`
|
||||
: `SELECT * FROM shipment_tracking WHERE deliveredAt IS NULL ORDER BY detectedAt`;
|
||||
const params = workOrderId ? [workOrderId] : [];
|
||||
db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows || [])));
|
||||
});
|
||||
}
|
||||
|
||||
function dbCountActiveForWo(db, workOrderId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT COUNT(*) AS n FROM shipment_tracking WHERE workOrderId = ? AND deliveredAt IS NULL`,
|
||||
[workOrderId],
|
||||
(err, row) => (err ? reject(err) : resolve(row?.n ?? 0))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getSender(webex) {
|
||||
return webex && typeof webex.sendMarkdown === 'function' ? webex : webexService;
|
||||
}
|
||||
|
||||
export function logTrackingBootStatus() {
|
||||
if (bootLogged) return;
|
||||
bootLogged = true;
|
||||
if (!isShipmentTrackingEnabled()) {
|
||||
logger('shipmentTracking', 'Disabled (SHIPMENT_TRACKING_ENABLED or missing FedEx creds)', 'info');
|
||||
return;
|
||||
}
|
||||
if (!isFedExConfigured()) {
|
||||
logger('shipmentTracking', 'Enabled but FedEx credentials missing — tracking inactive', 'warn');
|
||||
return;
|
||||
}
|
||||
logger('shipmentTracking', 'FedEx shipment tracking active', 'info');
|
||||
}
|
||||
|
||||
export async function registerShipmentsFromNote({
|
||||
db,
|
||||
webex,
|
||||
workOrderId,
|
||||
roomId,
|
||||
woNumber,
|
||||
noteText,
|
||||
}) {
|
||||
if (!isShipmentTrackingEnabled() || !isFedExConfigured()) return { registered: 0 };
|
||||
if (!db || !roomId || !workOrderId || !noteText) return { registered: 0 };
|
||||
|
||||
const numbers = parseFedExTrackingNumbers(noteText);
|
||||
if (!numbers.length) return { registered: 0 };
|
||||
|
||||
const sender = getSender(webex);
|
||||
const newlyRegistered = [];
|
||||
|
||||
for (const trackingNumber of numbers) {
|
||||
const existing = await dbGetShipment(db, workOrderId, trackingNumber);
|
||||
if (existing) continue;
|
||||
|
||||
const inserted = await dbInsertShipment(db, {
|
||||
workOrderId,
|
||||
roomId,
|
||||
trackingNumber,
|
||||
detectedAt: new Date().toISOString(),
|
||||
sourceNote: noteText.substring(0, 500),
|
||||
});
|
||||
|
||||
if (inserted) newlyRegistered.push(trackingNumber);
|
||||
}
|
||||
|
||||
if (!newlyRegistered.length) return { registered: 0 };
|
||||
|
||||
const lines = newlyRegistered.map(
|
||||
(n) => `- [${n}](${fedExTrackUrl(n)})`
|
||||
);
|
||||
const woLabel = woNumber ? `WO-${woNumber}` : `WO ${workOrderId}`;
|
||||
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.`;
|
||||
|
||||
try {
|
||||
await sender.sendMarkdown(roomId, md);
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Initial post failed for WO ${workOrderId}: ${err.message}`, 'warn');
|
||||
}
|
||||
|
||||
logger(
|
||||
'shipmentTracking',
|
||||
`Registered ${newlyRegistered.length} tracking number(s) for WO ${workOrderId}: ${newlyRegistered.join(', ')}`
|
||||
);
|
||||
|
||||
return { registered: newlyRegistered.length, trackingNumbers: newlyRegistered };
|
||||
}
|
||||
|
||||
async function applyTrackResult(db, row, result, webex, woNumberByWoId) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (!result.ok) {
|
||||
logger(
|
||||
'shipmentTracking',
|
||||
`Track failed for ${row.trackingNumber} (WO ${row.workOrderId}): ${result.error}`,
|
||||
'warn'
|
||||
);
|
||||
await dbUpdateShipment(db, row.trackingNumber, row.workOrderId, {
|
||||
lastCheckedAt: now,
|
||||
statusCode: row.statusCode,
|
||||
statusDescription: row.statusDescription,
|
||||
estimatedDelivery: row.estimatedDelivery,
|
||||
deliveredAt: row.deliveredAt,
|
||||
lastPostedStatus: row.lastPostedStatus,
|
||||
});
|
||||
return { updated: false, delivered: false };
|
||||
}
|
||||
|
||||
const statusLabel = formatStatusLabel(result);
|
||||
const delivered = isDelivered(result.statusCode, result.statusDescription);
|
||||
const deliveredAt = delivered ? (result.deliveredAt || now) : null;
|
||||
const statusChanged = statusLabel !== row.lastPostedStatus;
|
||||
const newlyDelivered = delivered && !row.deliveredAt;
|
||||
|
||||
await dbUpdateShipment(db, row.trackingNumber, row.workOrderId, {
|
||||
statusCode: result.statusCode,
|
||||
statusDescription: result.statusDescription,
|
||||
estimatedDelivery: result.estimatedDelivery,
|
||||
deliveredAt,
|
||||
lastCheckedAt: now,
|
||||
lastPostedStatus: statusChanged ? statusLabel : row.lastPostedStatus,
|
||||
});
|
||||
|
||||
if (statusChanged) {
|
||||
const sender = getSender(webex);
|
||||
const woNum = woNumberByWoId?.get(row.workOrderId) || row.workOrderId;
|
||||
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);
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Update post failed for ${row.trackingNumber}: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
if (newlyDelivered) {
|
||||
const opsNotified = await notifyOpsOnDelivery(webex, row);
|
||||
if (opsNotified) {
|
||||
await dbMarkOpsDeliveredNotified(db, row.workOrderId, row.trackingNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (delivered) {
|
||||
const remaining = await dbCountActiveForWo(db, row.workOrderId);
|
||||
if (remaining === 0) {
|
||||
const sender = getSender(webex);
|
||||
const woNum = woNumberByWoId?.get(row.workOrderId) || row.workOrderId;
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function pollActiveShipments({ db, webex, workOrderId = null } = {}) {
|
||||
if (!isShipmentTrackingEnabled() || !isFedExConfigured()) {
|
||||
return { checked: 0, updated: 0, delivered: 0, shipments: [], skipped: true };
|
||||
}
|
||||
if (!db) return { checked: 0, updated: 0, delivered: 0, shipments: [], skipped: true };
|
||||
|
||||
const active = await dbGetActiveShipments(db, workOrderId);
|
||||
if (!active.length) return { checked: 0, updated: 0, delivered: 0, shipments: [] };
|
||||
|
||||
let updated = 0;
|
||||
let delivered = 0;
|
||||
const shipments = [];
|
||||
|
||||
for (let i = 0; i < active.length; i += BATCH_SIZE) {
|
||||
const batch = active.slice(i, i + BATCH_SIZE);
|
||||
const numbers = batch.map((r) => r.trackingNumber);
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = await trackByNumbers(numbers);
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Batch track failed: ${err.message}`, 'error');
|
||||
for (const row of batch) {
|
||||
shipments.push({
|
||||
trackingNumber: row.trackingNumber,
|
||||
ok: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
for (const row of batch) {
|
||||
const result = results.get(row.trackingNumber) || {
|
||||
ok: false,
|
||||
error: 'Missing from batch response',
|
||||
};
|
||||
const outcome = await applyTrackResult(db, row, result, webex, null);
|
||||
if (outcome.updated) updated++;
|
||||
if (outcome.delivered) delivered++;
|
||||
shipments.push(shipmentSnapshotFromTrackResult(row, result));
|
||||
}
|
||||
}
|
||||
|
||||
logger(
|
||||
'shipmentTracking',
|
||||
`Poll complete: checked=${active.length}, updated=${updated}, delivered=${delivered}` +
|
||||
(workOrderId ? ` (WO ${workOrderId})` : '')
|
||||
);
|
||||
|
||||
return { checked: active.length, updated, delivered, shipments };
|
||||
}
|
||||
|
||||
export async function pollShipmentsForWorkOrder({ db, webex, workOrderId }) {
|
||||
return pollActiveShipments({ db, webex, workOrderId });
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot backfill: scan all mapped WO spaces for FedEx numbers in SC notes,
|
||||
* insert into shipment_tracking without WO-room registration posts, then (live
|
||||
* mode) poll FedEx once. Option B: already-delivered packages get deliveredAt +
|
||||
* opsDeliveredNotifiedAt without ops-room notification; lastPostedStatus is set
|
||||
* so cron won't flood WO rooms.
|
||||
*/
|
||||
export async function backfillShipmentsFromAllMappings({ db, dryRun = true } = {}) {
|
||||
const mode = dryRun ? 'DRY RUN' : 'LIVE';
|
||||
|
||||
if (!db) {
|
||||
return { error: 'Database not available', dryRun, mode };
|
||||
}
|
||||
if (!isShipmentTrackingEnabled() || !isFedExConfigured()) {
|
||||
return {
|
||||
error: 'Shipment tracking disabled or FedEx credentials missing',
|
||||
dryRun,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
const mappings = await dbGetAllMappings(db);
|
||||
const limit = pLimit(BACKFILL_CONCURRENCY);
|
||||
const results = [];
|
||||
const newlyInsertedRows = [];
|
||||
|
||||
const summary = {
|
||||
mappingsScanned: mappings.length,
|
||||
mappingsWithTracking: 0,
|
||||
trackingNumbersFound: 0,
|
||||
registered: 0,
|
||||
alreadyTracked: 0,
|
||||
fedExChecked: 0,
|
||||
alreadyDelivered: 0,
|
||||
inTransit: 0,
|
||||
trackErrors: 0,
|
||||
noteFetchErrors: 0,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
mappings.map((mapping) =>
|
||||
limit(async () => {
|
||||
const { workOrderId, roomId } = mapping;
|
||||
const rowResult = {
|
||||
workOrderId,
|
||||
roomId,
|
||||
trackingNumbers: [],
|
||||
registered: [],
|
||||
alreadyTracked: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const notes = await fetchWorkOrderNotes(workOrderId);
|
||||
const numbers = collectTrackingNumbersFromNotes(notes);
|
||||
rowResult.trackingNumbers = numbers;
|
||||
summary.trackingNumbersFound += numbers.length;
|
||||
if (numbers.length) summary.mappingsWithTracking++;
|
||||
|
||||
for (const trackingNumber of numbers) {
|
||||
const existing = await dbGetShipment(db, workOrderId, trackingNumber);
|
||||
if (existing) {
|
||||
rowResult.alreadyTracked.push(trackingNumber);
|
||||
summary.alreadyTracked++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
rowResult.registered.push(trackingNumber);
|
||||
summary.registered++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inserted = await dbInsertShipment(db, {
|
||||
workOrderId,
|
||||
roomId,
|
||||
trackingNumber,
|
||||
detectedAt: new Date().toISOString(),
|
||||
sourceNote: 'backfill',
|
||||
});
|
||||
|
||||
if (inserted) {
|
||||
rowResult.registered.push(trackingNumber);
|
||||
summary.registered++;
|
||||
const row = await dbGetShipment(db, workOrderId, trackingNumber);
|
||||
if (row) newlyInsertedRows.push(row);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
rowResult.error = err.message;
|
||||
summary.noteFetchErrors++;
|
||||
logger(
|
||||
'shipmentTracking',
|
||||
`Backfill note fetch failed for WO ${workOrderId}: ${err.message}`,
|
||||
'warn'
|
||||
);
|
||||
}
|
||||
|
||||
if (rowResult.trackingNumbers.length || rowResult.error) {
|
||||
results.push(rowResult);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
results.sort((a, b) => String(a.workOrderId).localeCompare(String(b.workOrderId)));
|
||||
|
||||
if (!dryRun && newlyInsertedRows.length) {
|
||||
for (let i = 0; i < newlyInsertedRows.length; i += BATCH_SIZE) {
|
||||
const batch = newlyInsertedRows.slice(i, i + BATCH_SIZE);
|
||||
const numbers = batch.map((r) => r.trackingNumber);
|
||||
|
||||
let trackResults;
|
||||
try {
|
||||
trackResults = await trackByNumbers(numbers);
|
||||
} catch (err) {
|
||||
logger('shipmentTracking', `Backfill FedEx batch failed: ${err.message}`, 'error');
|
||||
summary.trackErrors += batch.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const row of batch) {
|
||||
summary.fedExChecked++;
|
||||
const result = trackResults.get(row.trackingNumber) || {
|
||||
ok: false,
|
||||
error: 'Missing from batch response',
|
||||
};
|
||||
const outcome = await applyBackfillTrackResult(db, row, result);
|
||||
if (outcome.error) summary.trackErrors++;
|
||||
else if (outcome.delivered) summary.alreadyDelivered++;
|
||||
else if (outcome.inTransit) summary.inTransit++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger('shipmentTracking', `Backfill complete dryRun=${dryRun} ${JSON.stringify(summary)}`);
|
||||
|
||||
return { dryRun, mode, summary, results };
|
||||
}
|
||||
|
||||
export default {
|
||||
parseFedExTrackingNumbers,
|
||||
logTrackingBootStatus,
|
||||
registerShipmentsFromNote,
|
||||
pollActiveShipments,
|
||||
pollShipmentsForWorkOrder,
|
||||
backfillShipmentsFromAllMappings,
|
||||
formatShipmentStatusMarkdown,
|
||||
};
|
||||
|
|
@ -35,6 +35,7 @@ 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';
|
||||
|
||||
export function createWebhookProcessor(options = {}) {
|
||||
|
|
@ -241,6 +242,19 @@ export function createWebhookProcessor(options = {}) {
|
|||
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')) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue