Add /createWO, WO file upload, and suppress SC upload webhook echo.

Enable ServiceChannel work order creation from DM, upload attachments from
WO spaces via /attach and /upload, and skip note/attachment echo for
bot-originated uploads. Also fixes duplicate FedEx notifications and adds
multi-instance command dedup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-08-26 17:22:21 -04:00
parent d6549c07f8
commit 0fc3eb38fa
24 changed files with 1649 additions and 115 deletions

View file

@ -117,6 +117,28 @@ SC_WEBHOOK_AUTH_MODE=off # off | log | enforce
# Set to false to disable automatic posting (manual /woAttachments still works).
# AUTO_POST_ATTACHMENTS=true
# --- /createWO — ServiceChannel work order creation from DM (optional) ---
# SC_CREATE_ENABLED=true
# SC_CREATE_DEFAULT_TRADE=AUDIO & VIDEO
# SC_CREATE_DEFAULT_PROVIDER_ID=2000002215
# SC_CREATE_DEFAULT_PROVIDER_NAME=Pro-Motion Technology Group, LLC
# SC_CREATE_DEFAULT_CATEGORY=REPAIR
# SC_CREATE_DEFAULT_PRIORITY=P24
# SC_CREATE_DEFAULT_NTE=500
# SC_CREATE_CATEGORIES=REPAIR,MAINTENANCE,PROJECT
# SC_CREATE_PRIORITIES=P24,P1 - 4 Hours,P2 - 8 Hours
# SC_CREATE_PROBLEM_CODES=Music completely out,Needs service,Partial Music
# SC_CREATE_REQUIRE_ISSUELIST=false
# SC_CREATE_ISSUE_AREA_ID=
# SC_CREATE_ISSUE_EXTENDED_AREA_NAME=
# SC_CREATE_ISSUE_PROBLEM_TYPE=
# SC_CREATE_ISSUE_ASSET_TYPE=
# SC_CREATE_STORE_PAD_LENGTH=6
# Max file size (bytes) for /attach and /upload from WO spaces (default 25 MB)
# SC_UPLOAD_MAX_BYTES=26214400
# Admin: GET /create-wo-discovery?trade=Audio to inspect recent tenant field values
# --- Runtime configuration (non-secret) ---
DB_PATH=./data/webex_sc_mappings.db
# Directory for daily *.log files. Every logger in the app resolves to this

View file

@ -33,7 +33,7 @@ Users interact with the bot via mentions in Webex:
- The Webex Framework is initialized in `src/bot/index.js`.
- All commands are routed through a single `Framework.hears(...)` handler.
- Commands are dispatched to handlers in `src/commands/` (`help.js`, `avStatus.js`, `woSummary.js`, etc.).
- Commands are dispatched to handlers in `src/commands/` (`help.js`, `avStatus.js`, `woSummary.js`, `woAttach.js`, etc.).
- Many of these handlers call the external CollabSupport service (configured via `CS_API_BASE`) rather than doing heavy work locally.
### Key Classes / Modules and Their Responsibilities
@ -180,25 +180,25 @@ npm run dev
### Docker (recommended for consistency)
**Development** (live reload, source mounted, node --watch):
**Production** (default — optimized image, no source mount, `npm start`):
```bash
docker compose up --build
```
**Production** (optimized image, no source mount, persistent logs volume):
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
docker compose up -d --build
```
After the first build you can usually omit `--build`:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose up -d
```
See [docker-compose.prod.yml](docker-compose.prod.yml) for the production-specific settings.
**Development** (live reload via `node --watch` — restarts on file changes; dev only):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
See [docker-compose.yml](docker-compose.yml) and [docker-compose.dev.yml](docker-compose.dev.yml) for details.
**Important for production images**:
- All secrets must come from `.env` (via `env_file`). The application now enforces this via `src/config/secrets.js`.
@ -234,6 +234,10 @@ See [docker-compose.prod.yml](docker-compose.prod.yml) for the production-specif
| `/woAttachments` | In WO space or with WO number | Often calls remote service |
| `/woHistory` | With store number | Often calls remote service |
| `/woApprove` | In WO space (or with WO#) | Manually posts proposal approval Adaptive Card (when status is IN PROGRESS \| WAITING FOR APPROVAL). Auto-triggered on relevant webhooks too. |
| `/attach` / `/upload` | WO space only | Upload a photo or file to the linked ServiceChannel work order; optional caption becomes an SC note. |
| `/createWO` | **1:1 DM only** | Adaptive Card to create a new work order in ServiceChannel; Webex group space is created automatically via webhook. |
**Create WO (new)**: In a direct message with ServChan, run `/createWO` to open a form (store, description, category, priority, optional problem code/NTE). Defaults come from env (`SC_CREATE_DEFAULT_*`). Admin discovery: `GET /create-wo-discovery?trade=Audio` (requires `ADMIN_TOKEN`).
**Approval Cards (new)**: When a webhook arrives with `IN PROGRESS | WAITING FOR APPROVAL` (typically with a "Proposal created" note), ServChan automatically posts an Adaptive Card v1.3 in the room. The card shows proposal details/costs, prepopulates the suggested new NTE (current + proposal sums), and allows submit to PATCH the NTE in ServiceChannel + record approver attribution from the Webex user. Use `/woApprove` to re-trigger.

View file

@ -141,6 +141,44 @@ 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)`);
}
}
);
db.run(`
CREATE TABLE IF NOT EXISTS bot_message_dedup (
messageId TEXT NOT NULL,
command TEXT NOT NULL,
processedAt TEXT NOT NULL,
PRIMARY KEY (messageId, command)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS upload_echo_suppress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workOrderId INTEGER NOT NULL,
attachmentId INTEGER,
fileName TEXT,
noteText TEXT,
expiresAt TEXT NOT NULL
)
`);
db.run(`
CREATE INDEX IF NOT EXISTS idx_upload_echo_suppress_lookup
ON upload_echo_suppress(workOrderId, expiresAt)
`);
console.log(`[DB] Connected to ${DB_PATH}`);
});

View file

@ -7,6 +7,7 @@
*/
import framework from 'webex-node-bot-framework';
import os from 'node:os';
import { logger } from '../utils/logger.js';
import { handleHelp } from '../commands/help.js';
@ -18,10 +19,13 @@ 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 { handleWoAttach } from '../commands/woAttach.js';
import { handleWoTrackStatus } from '../commands/woTrackStatus.js';
import { handleWoTrackTest } from '../commands/woTrackTest.js';
import { handleWoCreate } from '../commands/woCreate.js';
import approvalService from '../services/approvalService.js';
import invoiceApprovalService from '../services/invoiceApprovalService.js';
import workOrderCreateService from '../services/workOrderCreateService.js';
import { installMercuryGuard } from './mercuryGuard.js';
import db from '../db/mappings.js';
@ -46,6 +50,8 @@ export function initializeBot({ webexConfig }) {
Framework.start();
logger('bot', `ServChan bot instance pid=${process.pid} hostname=${os.hostname()}`);
// Prevent Mercury websocket INVALID_STATE_ERROR from crashing Node (see
// src/bot/mercuryGuard.js for details). Must be installed before events flow.
installMercuryGuard(Framework);
@ -77,6 +83,10 @@ export function initializeBot({ webexConfig }) {
await invoiceApprovalService.handleInvoiceApprovalSubmit(bot, trigger, { db });
return;
}
if (actionType === 'createWorkOrder' || actionType === 'dismissCreateWoCard') {
await workOrderCreateService.handleCreateWorkOrderSubmit(bot, trigger);
return;
}
await approvalService.handleApprovalSubmit(bot, trigger, { db });
} catch (e) {
logger('bot:approvalAction', `Handler error: ${e.message}`, 'error');
@ -141,12 +151,19 @@ export function initializeBot({ webexConfig }) {
case 'addnote':
await handleWoAddNote(bot, trigger);
break;
case 'attach':
case 'upload':
await handleWoAttach(bot, trigger);
break;
case 'trackstatus':
await handleWoTrackStatus(bot, trigger);
break;
case 'tracktest':
await handleWoTrackTest(bot, trigger);
break;
case 'createwo':
await handleWoCreate(bot, trigger);
break;
default:
await handleUnknown(bot, trigger);
break;

View file

@ -12,10 +12,12 @@ 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 += `- **/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 += `- **/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 += `- **/createWO** — create a new ServiceChannel work order (Adaptive Card form)\n`;
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`;

View file

@ -4,6 +4,7 @@ import { addWorkOrderNote } from '../integrations/serviceChannel/client.js';
import db from '../db/mappings.js';
import { logger } from '../utils/logger.js';
import { resolveDisplayName, resolveWoRoomContext } from '../utils/woRoomContext.js';
import { tryClaimBotMessage } from '../db/botMessageDedup.js';
export async function handleWoAddNote(bot, trigger) {
logger('wo:addNote', 'HANDLER ENTERED');
@ -23,6 +24,17 @@ export async function handleWoAddNote(bot, trigger) {
return;
}
const messageId = trigger.message?.id;
try {
const claimed = await tryClaimBotMessage(db, messageId, 'addNote');
if (!claimed) {
logger('wo:addNote', `Skipped duplicate /addNote for message ${messageId}`, 'info');
return;
}
} catch (err) {
logger('wo:addNote', `Dedup check failed: ${err.message}`, 'warn');
}
const displayName = await resolveDisplayName(botClient, trigger.personId);
const scNote = `${displayName}: ${noteText}`;

42
src/commands/woAttach.js Normal file
View file

@ -0,0 +1,42 @@
// src/commands/woAttach.js
import botClient from '../integrations/webex/botClient.js';
import db from '../db/mappings.js';
import { logger } from '../utils/logger.js';
import { uploadFilesFromTrigger } from '../services/workOrderUploadService.js';
export async function handleWoAttach(bot, trigger) {
logger('wo:attach', 'HANDLER ENTERED');
const isGroup = trigger.message?.roomType === 'group';
const caption = (trigger.args || []).join(' ').trim();
if (!isGroup) {
await bot.say('Use `/attach` or `/upload` in a ServChan work order space with a file attached to the message.');
return;
}
try {
const result = await uploadFilesFromTrigger({ db, botClient, trigger, caption });
if (!result.ok) {
if (result.error) await bot.say(result.error);
return;
}
const names = result.uploaded.map((u) => u.name);
let msg;
if (names.length === 1) {
msg = `Uploaded **${names[0]}** to ServiceChannel.`;
} else {
msg = `Uploaded **${names.length}** file(s) to ServiceChannel: ${names.map((n) => `**${n}**`).join(', ')}.`;
}
if (result.captionPosted) {
msg += ' Caption added as a note.';
}
await bot.say(msg);
} catch (err) {
logger('wo:attach', `Upload failed: ${err.message}`, 'error');
await bot.say(`Could not upload to ServiceChannel: ${err.message}`);
}
}

27
src/commands/woCreate.js Normal file
View file

@ -0,0 +1,27 @@
// src/commands/woCreate.js
// DM-only: post Adaptive Card to create a ServiceChannel work order.
import { logger } from '../utils/logger.js';
import {
postCreateWorkOrderCard,
} from '../services/workOrderCreateService.js';
import { isWorkOrderCreateEnabled } from '../config/workOrderCreateConfig.js';
export async function handleWoCreate(bot, trigger) {
logger('wo:create', 'HANDLER ENTERED');
if (trigger.message?.roomType === 'group') {
await bot.say(
'**`/createWO`** is only available in a **1:1** chat with ServChan.\n\n' +
'Open a direct message with the bot and run `/createWO` there.'
);
return;
}
if (!isWorkOrderCreateEnabled()) {
await bot.say('Work order creation is disabled on this bot.');
return;
}
await postCreateWorkOrderCard(bot, trigger);
}

View file

@ -50,12 +50,14 @@ export async function handleWoTrackStatus(bot, trigger) {
const lines = (result.shipments || []).map((s) => formatShipmentStatusMarkdown(s));
const summary =
`${result.checked} checked · ${result.updated} status update(s) posted · ${result.delivered} newly delivered`;
result.checked === 1
? '1 shipment checked'
: `${result.checked} shipments checked`;
await bot.say({
markdown:
`**FedEx tracking refresh**\n\n` +
`${lines.join('\n')}\n\n` +
`_${summary}_`,
`_${summary}${result.delivered ? ` · ${result.delivered} newly delivered` : ''}_`,
});
}

View file

@ -2,7 +2,7 @@
// 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 { isFedExConfigured, trackByNumbers, formatFedExStatusLine } from '../integrations/fedex/client.js';
import { parseFedExTrackingNumbers } from '../services/shipmentTrackingService.js';
const PLAIN_NUMBER_RE = /^\d{12,22}$/;
@ -28,16 +28,7 @@ function formatResult(result) {
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;
return `- **[${result.trackingNumber}](${url})** — ${formatFedExStatusLine(result)}`;
}
export async function handleWoTrackTest(bot, trigger) {

View file

@ -0,0 +1,81 @@
/**
* Configuration for /createWO ServiceChannel work order creation defaults.
* Values can be overridden via environment variables; run GET /create-wo-discovery
* (admin) to inspect recent tenant-specific field samples.
*/
function parseCsvEnv(name, fallback = []) {
const raw = process.env[name];
if (!raw || !String(raw).trim()) return [...fallback];
return String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
function parseNumberEnv(name, fallback) {
const raw = process.env[name];
if (raw == null || raw === '') return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
}
function parseBoolEnv(name, fallback = false) {
const raw = process.env[name];
if (raw == null || raw === '') return fallback;
return /^(1|true|yes|on)$/i.test(String(raw).trim());
}
export function isWorkOrderCreateEnabled() {
return parseBoolEnv('SC_CREATE_ENABLED', true);
}
export function getWorkOrderCreateConfig() {
const providerId = parseNumberEnv(
'SC_CREATE_DEFAULT_PROVIDER_ID',
parseNumberEnv('SPACE_CLEANUP_PROVIDER_ID', 2000002215)
);
const issueAreaId = parseNumberEnv('SC_CREATE_ISSUE_AREA_ID', null);
return {
enabled: isWorkOrderCreateEnabled(),
tradeName: process.env.SC_CREATE_DEFAULT_TRADE || 'AUDIO & VIDEO',
providerId,
providerName:
process.env.SC_CREATE_DEFAULT_PROVIDER_NAME ||
'Pro-Motion Technology Group, LLC',
storePadLength: parseNumberEnv('SC_CREATE_STORE_PAD_LENGTH', 6),
defaultCategory: process.env.SC_CREATE_DEFAULT_CATEGORY || 'REPAIR',
defaultPriority: process.env.SC_CREATE_DEFAULT_PRIORITY || 'P24',
defaultNte: parseNumberEnv('SC_CREATE_DEFAULT_NTE', 500),
categories: parseCsvEnv('SC_CREATE_CATEGORIES', ['REPAIR', 'MAINTENANCE', 'PROJECT']),
priorities: parseCsvEnv('SC_CREATE_PRIORITIES', [
'P24',
'P1 - 4 Hours',
'P2 - 8 Hours',
'P3 - 24 Hours',
'P4 - 72 Hours',
]),
problemCodes: parseCsvEnv('SC_CREATE_PROBLEM_CODES', [
'Music completely out',
'Needs service',
'Partial Music',
]),
requireIssueList: parseBoolEnv('SC_CREATE_REQUIRE_ISSUELIST', false),
issueRequestInfo: issueAreaId
? {
AreaId: issueAreaId,
ExtendedAreaName: process.env.SC_CREATE_ISSUE_EXTENDED_AREA_NAME || '',
ProblemType: process.env.SC_CREATE_ISSUE_PROBLEM_TYPE || '',
AssetType: process.env.SC_CREATE_ISSUE_ASSET_TYPE || '',
}
: null,
allowedPersonIds: parseCsvEnv('SC_CREATE_ALLOWED_PERSON_IDS', []),
};
}
export function isPersonAllowedToCreateWorkOrder(personId, config = getWorkOrderCreateConfig()) {
if (!config.allowedPersonIds.length) return true;
return config.allowedPersonIds.includes(String(personId || '').trim());
}

20
src/db/botMessageDedup.js Normal file
View file

@ -0,0 +1,20 @@
/**
* Cross-process command dedup via SQLite.
* When multiple bot containers share the same DB, only the first INSERT wins.
*/
export function tryClaimBotMessage(db, messageId, command) {
if (!db || !messageId || !command) return Promise.resolve(true);
return new Promise((resolve, reject) => {
db.run(
`INSERT OR IGNORE INTO bot_message_dedup (messageId, command, processedAt)
VALUES (?, ?, ?)`,
[messageId, command, new Date().toISOString()],
function onRun(err) {
if (err) reject(err);
else resolve(this.changes > 0);
}
);
});
}

View file

@ -0,0 +1,168 @@
/**
* Suppress ServiceChannel webhook echo for files uploaded via /attach or /upload.
* SC auto-generates "Attachment '…' has been added" notes (with AttachmentIds)
* which would otherwise re-post notes and files into the same Webex WO space.
*/
import { logger } from '../utils/logger.js';
const DEFAULT_TTL_MS = 10 * 60 * 1000;
function normalizeFileName(name) {
try {
return decodeURIComponent(String(name || '').trim()).toLowerCase();
} catch {
return String(name || '').trim().toLowerCase();
}
}
function normalizeNoteText(text) {
return String(text || '').trim();
}
function expiresAtFromTtl(ttlMs = DEFAULT_TTL_MS) {
return new Date(Date.now() + ttlMs).toISOString();
}
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function onRun(err) {
if (err) reject(err);
else resolve(this);
});
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => (err ? reject(err) : resolve(row ?? null)));
});
}
export function isScAttachmentAddedNote(noteData) {
return /^Attachment\s+'.+'\s+has been added\./i.test(normalizeNoteText(noteData));
}
export function extractAttachmentFileNameFromNote(noteData) {
const m = normalizeNoteText(noteData).match(/^Attachment\s+'([^']+)'\s+has been added\./i);
return m ? m[1] : null;
}
export async function registerUploadEchoSuppress(
db,
workOrderId,
{ attachmentId = null, fileName = null, noteText = null, ttlMs = DEFAULT_TTL_MS } = {}
) {
if (!db || !workOrderId) return;
await dbRun(
db,
`INSERT INTO upload_echo_suppress (workOrderId, attachmentId, fileName, noteText, expiresAt)
VALUES (?, ?, ?, ?, ?)`,
[
workOrderId,
attachmentId,
fileName ? normalizeFileName(fileName) : null,
noteText ? normalizeNoteText(noteText) : null,
expiresAtFromTtl(ttlMs),
]
);
}
export async function markAttachmentPosted(db, workOrderId, attachmentId) {
if (!db || !workOrderId || attachmentId == null) return;
await dbRun(
db,
`INSERT OR IGNORE INTO posted_attachments (workOrderId, attachmentId, postedAt)
VALUES (?, ?, ?)`,
[workOrderId, attachmentId, new Date().toISOString()]
);
}
async function hasActiveSuppress(db, workOrderId, { attachmentIds = [], fileName = null, noteText = null }) {
const now = new Date().toISOString();
const ids = (attachmentIds || []).filter((id) => id != null);
const normalizedName = fileName ? normalizeFileName(fileName) : null;
const normalizedNote = noteText ? normalizeNoteText(noteText) : null;
if (normalizedNote) {
const row = await dbGet(
db,
`SELECT 1 FROM upload_echo_suppress
WHERE workOrderId = ? AND noteText = ? AND expiresAt > ?
LIMIT 1`,
[workOrderId, normalizedNote, now]
);
if (row) return true;
}
if (normalizedName) {
const row = await dbGet(
db,
`SELECT 1 FROM upload_echo_suppress
WHERE workOrderId = ? AND fileName = ? AND expiresAt > ?
LIMIT 1`,
[workOrderId, normalizedName, now]
);
if (row) return true;
}
for (const id of ids) {
const row = await dbGet(
db,
`SELECT 1 FROM upload_echo_suppress
WHERE workOrderId = ? AND attachmentId = ? AND expiresAt > ?
LIMIT 1`,
[workOrderId, id, now]
);
if (row) return true;
}
return false;
}
export async function shouldSuppressWebhookNote(db, workOrderId, note = {}) {
if (!db || !workOrderId || !note) return false;
const noteData = note.NoteData || note.noteData || '';
const attachmentIds = note.AttachmentIds || note.attachmentIds || [];
if (await hasActiveSuppress(db, workOrderId, { noteText: noteData, attachmentIds })) {
return true;
}
if (isScAttachmentAddedNote(noteData)) {
const fileName = extractAttachmentFileNameFromNote(noteData);
if (await hasActiveSuppress(db, workOrderId, { fileName, attachmentIds })) {
logger(
'uploadEchoSuppress',
`Suppressing SC attachment note echo for WO ${workOrderId}: ${fileName || noteData}`
);
return true;
}
}
return false;
}
export async function shouldSuppressAttachmentPost(db, workOrderId, attachmentIds = []) {
if (!db || !workOrderId || !attachmentIds?.length) return false;
const now = new Date().toISOString();
for (const id of attachmentIds) {
const row = await dbGet(
db,
`SELECT 1 FROM upload_echo_suppress
WHERE workOrderId = ? AND attachmentId = ? AND expiresAt > ?
LIMIT 1`,
[workOrderId, id, now]
);
if (!row) return false;
}
logger(
'uploadEchoSuppress',
`Suppressing SC attachment post echo for WO ${workOrderId}: ids=${attachmentIds.join(',')}`
);
return true;
}

View file

@ -75,33 +75,125 @@ async function getFedExToken(forceRefresh = false) {
}
}
const FEDEX_STATUS_LABELS = {
DL: 'Delivered',
OD: 'Out for delivery',
IT: 'On the way',
PU: 'Picked up',
OC: 'Label created',
SE: 'Shipment exception',
CA: 'Canceled',
};
function formatScanLocation(loc) {
if (!loc) return '';
const parts = [loc.city, loc.stateOrProvinceCode, loc.countryCode === 'US' ? null : loc.countryCode]
.filter(Boolean);
return parts.join(', ');
}
function pickEstimatedDelivery(trackResult) {
const edtw = trackResult?.estimatedDeliveryTimeWindow?.window;
if (edtw?.ends) {
return { start: edtw.begins || null, end: edtw.ends };
}
const std = trackResult?.standardTransitTimeWindow?.window;
if (std?.ends) {
return { start: std.begins || null, end: std.ends };
}
const dateTimes = trackResult?.dateAndTimes || [];
const estimates = dateTimes
.filter((d) => String(d.type || d.dateTimeType || '').toUpperCase() === 'ESTIMATED_DELIVERY')
.map((d) => d.dateTime)
.filter(Boolean)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
if (estimates.length) {
return { start: null, end: estimates[0] };
}
return { start: null, end: null };
}
export function formatFedExEta(estimatedDelivery, estimatedDeliveryStart = null) {
if (!estimatedDelivery) return null;
const end = new Date(estimatedDelivery);
if (Number.isNaN(end.getTime())) return null;
if (estimatedDeliveryStart) {
const start = new Date(estimatedDeliveryStart);
if (!Number.isNaN(start.getTime()) && start.toDateString() === end.toDateString()) {
const fmt = { hour: 'numeric', minute: '2-digit' };
return `${end.toLocaleDateString('en-US')}, ${start.toLocaleTimeString('en-US', fmt)}${end.toLocaleTimeString('en-US', fmt)}`;
}
}
return end.toLocaleDateString('en-US');
}
export function formatFedExStatusLine({
statusDescription = '',
statusCode = '',
estimatedDelivery = null,
estimatedDeliveryStart = null,
deliveredAt = null,
} = {}) {
const parts = [];
if (statusDescription) parts.push(statusDescription);
else if (statusCode) parts.push(FEDEX_STATUS_LABELS[statusCode] || statusCode);
else parts.push('Status unknown');
const eta = formatFedExEta(estimatedDelivery, estimatedDeliveryStart);
if (eta) parts.push(`ETA ${eta}`);
if (deliveredAt) {
parts.push(`Delivered ${new Date(deliveredAt).toLocaleDateString('en-US')}`);
}
return parts.join(' · ');
}
function buildStatusDescription(detail, latestScan, statusCode) {
const headline =
detail.statusByLocale ||
FEDEX_STATUS_LABELS[statusCode] ||
detail.description ||
latestScan?.derivedStatus ||
latestScan?.eventDescription ||
'';
const scanText = latestScan?.eventDescription || '';
const scanLoc = formatScanLocation(latestScan?.scanLocation || detail.scanLocation);
let scanDetail = scanText;
if (scanText && scanLoc && !scanText.toUpperCase().includes(scanLoc.toUpperCase())) {
scanDetail = `${scanText}, ${scanLoc}`;
} else if (!scanText && scanLoc) {
scanDetail = scanLoc;
}
if (!headline) return scanDetail || 'Status unknown';
if (!scanDetail || scanDetail.toLowerCase() === headline.toLowerCase()) return headline;
if (headline.toLowerCase().includes(scanDetail.toLowerCase())) return headline;
return `${headline} · ${scanDetail}`;
}
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;
}
const statusCode = String(detail.code || detail.derivedCode || latestScan?.eventType || '')
.trim()
.toUpperCase();
const statusDescription = buildStatusDescription(detail, latestScan, statusCode);
const { start: estimatedDeliveryStart, end: estimatedDelivery } = pickEstimatedDelivery(trackResult);
let deliveredAt = null;
if (String(statusCode).toUpperCase() === 'DL' || /delivered/i.test(statusDescription)) {
if (statusCode === 'DL' || /delivered/i.test(statusDescription)) {
const deliveredEvent = scanEvents.find((e) =>
String(e.eventType || '').toUpperCase() === 'DL' || /delivered/i.test(e.eventDescription || '')
);
@ -109,9 +201,10 @@ function pickLatestStatus(trackResult) {
}
return {
statusCode: String(statusCode || '').trim(),
statusDescription: String(statusDescription || '').trim(),
statusCode,
statusDescription,
estimatedDelivery,
estimatedDeliveryStart,
deliveredAt,
};
}
@ -196,4 +289,4 @@ export async function trackByNumbers(trackingNumbers = [], { _retried = false }
}
}
export default { isFedExConfigured, isShipmentTrackingEnabled, trackByNumbers };
export default { isFedExConfigured, isShipmentTrackingEnabled, trackByNumbers, formatFedExEta, formatFedExStatusLine };

View file

@ -2,7 +2,8 @@
* ServiceChannel work order attachment helpers (OData).
*/
import { fetchWithRetry } from './client.js';
import FormData from 'form-data';
import { fetchWithRetry, scAxios } from './client.js';
import { logger } from '../../utils/logger.js';
const ATTACHMENT_SELECT =
@ -130,3 +131,57 @@ export async function downloadAttachment(uri, fileName = 'attachment') {
return { buffer, contentType, fileName: safeName };
}
function summarizeUploadError(err) {
const data = err?.response?.data;
if (data == null) return err.message || 'Upload failed';
if (typeof data === 'string') return data;
if (data.Message) return String(data.Message);
try {
return JSON.stringify(data);
} catch {
return err.message || 'Upload failed';
}
}
/**
* Upload a file to an existing work order.
* POST /workorders/{workOrderId}/attachments (multipart/form-data).
*
* @returns {Promise<{ id: number|null, name: string, path: string|null }>}
*/
export async function uploadWorkOrderAttachment(workOrderId, buffer, fileName, contentType) {
if (!workOrderId || !buffer?.length || !fileName) {
throw new Error('workOrderId, buffer, and fileName are required');
}
const resolvedType = contentType || getContentTypeFromFilename(fileName);
const form = new FormData();
form.append('file', buffer, { filename: fileName, contentType: resolvedType });
const start = Date.now();
try {
const res = await scAxios.post(`/workorders/${workOrderId}/attachments`, form, {
headers: form.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
timeout: 120000,
});
const att = res.data?.Attachments?.[0] || {};
logger(
'sc:uploadAttachment',
`Uploaded ${fileName} to WO ${workOrderId} (id=${att.Id ?? 'unknown'}, ${Date.now() - start}ms)`
);
return {
id: att.Id ?? att.id ?? null,
name: att.Name || fileName,
path: att.Path ?? null,
};
} catch (err) {
const msg = summarizeUploadError(err);
logger('sc:uploadAttachment', `Failed ${fileName} for WO ${workOrderId}: ${msg}`, 'error');
throw new Error(msg);
}
}

View file

@ -788,3 +788,211 @@ export async function getProposalByIdOdataExpanded(proposalId) {
return null;
}
}
/**
* Normalize a user-entered store number to the SC storeIdentifier format.
* AE stores use 6-digit zero-padded IDs (e.g. 305 000305).
*/
export function normalizeStoreIdentifier(raw, padLength = 6) {
const digits = String(raw ?? '').trim().replace(/\D/g, '');
if (!digits) throw new Error('store number is required');
if (digits.length > padLength) {
throw new Error(`store number must be at most ${padLength} digits`);
}
return digits.padStart(padLength, '0');
}
/**
* Resolve a store identifier to ServiceChannel LocationId + canonical StoreId.
*/
export async function resolveLocationByStoreIdentifier(raw, { padLength = 6 } = {}) {
const normalized = normalizeStoreIdentifier(raw, padLength);
const res = await scAxios.get('/stores', {
params: { storeIdentifier: normalized },
});
const loc = res.data?.Locations?.[0] ?? null;
if (!loc?.Id) {
throw new Error(`Store ${normalized} was not found in ServiceChannel`);
}
return {
locationId: loc.Id,
storeId: loc.StoreId ?? normalized,
name: loc.Name ?? loc.ShortName ?? '',
};
}
/**
* Resolve the exact SC trade name assigned to a location.
*/
export async function resolveTradeNameForLocation(locationId, { preferredTrade = 'AUDIO & VIDEO' } = {}) {
const res = await scAxios.get(`/locations/${locationId}/trades`);
const trades = Array.isArray(res.data) ? res.data : (res.data?.value ?? []);
if (!trades.length) {
throw new Error(`No trades are assigned to location ${locationId}`);
}
const prefUpper = String(preferredTrade || '').toUpperCase();
const exact = trades.find((t) => String(t.Name || '').toUpperCase() === prefUpper);
if (exact?.Name) return exact.Name;
const fuzzyAudioVideo = trades.find((t) => {
const name = String(t.Name || '').toUpperCase();
return name.includes('AUDIO') && name.includes('VIDEO');
});
if (fuzzyAudioVideo?.Name) return fuzzyAudioVideo.Name;
throw new Error(`Trade "${preferredTrade}" is not assigned to this location`);
}
/**
* Resolve location and trade for WO creation. Provider is omitted so SC
* selects the contracted provider for the location + trade.
*/
export async function resolveCreateContractInfo(rawStoreId, {
padLength = 6,
preferredTrade = 'AUDIO & VIDEO',
} = {}) {
const location = await resolveLocationByStoreIdentifier(rawStoreId, { padLength });
const tradeName = await resolveTradeNameForLocation(location.locationId, { preferredTrade });
return {
...location,
tradeName,
};
}
/**
* Build a POST /workorders payload (Classic + optional IssueList fields).
*/
export function buildCreateWorkOrderPayload({
storeId,
locationId = null,
description,
category,
priority,
nte,
tradeName,
providerId = null,
problemCode = null,
callDate = null,
scheduledDate = null,
issueRequestInfo = null,
status = null,
} = {}) {
const store = String(storeId ?? '').trim();
const desc = String(description ?? '').trim();
const trade = String(tradeName ?? '').trim();
const provider = providerId != null ? Number(providerId) : null;
const nteValue = Number(nte);
const resolvedLocationId = locationId != null ? Number(locationId) : null;
if (!store && !Number.isFinite(resolvedLocationId)) throw new Error('storeId or locationId is required');
if (!desc) throw new Error('description is required');
if (!trade) throw new Error('tradeName is required');
if (!category) throw new Error('category is required');
if (!priority) throw new Error('priority is required');
if (!Number.isFinite(nteValue) || nteValue < 0) throw new Error('nte must be a non-negative number');
const contractInfo = {
TradeName: trade,
};
if (Number.isFinite(provider) && provider > 0) {
contractInfo.ProviderId = provider;
}
if (Number.isFinite(resolvedLocationId) && resolvedLocationId > 0) {
contractInfo.LocationId = resolvedLocationId;
} else {
contractInfo.StoreId = store;
}
const payload = {
ContractInfo: contractInfo,
Category: String(category).trim(),
Priority: String(priority).trim(),
Nte: nteValue,
CallDate: callDate || new Date().toISOString(),
Description: desc,
Status: status || { Primary: 'Open', Extended: '' },
};
if (scheduledDate) payload.ScheduledDate = scheduledDate;
if (problemCode) payload.ProblemCode = String(problemCode).trim();
if (issueRequestInfo && typeof issueRequestInfo === 'object') {
payload.IssueRequestInfo = issueRequestInfo;
}
return payload;
}
/**
* Create a work order in ServiceChannel.
* POST /workorders returns the created WO object (201).
*/
export async function createWorkOrder(payload) {
if (!payload || typeof payload !== 'object') {
throw new Error('createWorkOrder requires a payload object');
}
const res = await scAxios.post('/workorders', payload);
const woId = res.data?.Id ?? res.data?.id ?? null;
logger('sc:createWorkOrder', `Created WO ${woId ?? '(unknown id)'}`);
return res.data;
}
function _pickCreateFieldSample(row) {
return {
workOrderId: row.Id ?? row.id ?? null,
storeId: row.LocationStoreId ?? row.StoreId ?? null,
trade: row.Trade ?? null,
providerId: row.ProviderId ?? row.Provider?.Id ?? null,
providerName: row.ProviderName ?? row.Provider?.Name ?? null,
category: row.Category ?? null,
priority: row.Priority ?? null,
problemCode: row.ProblemCode ?? null,
nte: row.Nte ?? null,
};
}
function _uniqSorted(values) {
return [...new Set(values.filter((v) => v != null && String(v).trim() !== '').map(String))].sort();
}
/**
* Inspect recent work orders to suggest create-form defaults for this tenant.
* Does not create or mutate anything.
*/
export async function discoverWorkOrderCreateFields({ trade = null, limit = 25 } = {}) {
const params = {
$top: Math.min(Math.max(Number(limit) || 25, 1), 50),
$orderby: 'UpdatedDate desc',
};
if (trade) params.trade = trade;
const res = await scAxios.get('/workorders', { params });
const rows = res.data?.value || (Array.isArray(res.data) ? res.data : []);
const samples = rows.slice(0, params.$top).map(_pickCreateFieldSample);
const categories = _uniqSorted(samples.map((s) => s.category));
const priorities = _uniqSorted(samples.map((s) => s.priority));
const problemCodes = _uniqSorted(samples.map((s) => s.problemCode));
const trades = _uniqSorted(samples.map((s) => s.trade));
const providerIds = _uniqSorted(samples.map((s) => s.providerId));
return {
tradeFilter: trade || null,
sampleCount: samples.length,
samples,
suggested: {
categories,
priorities,
problemCodes,
trades,
providerIds,
},
};
}

View file

@ -174,6 +174,109 @@ class ServChanBotClient {
return null;
}
// ──────────────────────────────────────────────
// Inbound message file helpers
// ──────────────────────────────────────────────
async getMessage(messageId) {
if (!messageId) throw new Error('messageId is required');
const res = await this.axios.get(`/messages/${encodeURIComponent(messageId)}`);
return res.data;
}
#parseFilenameFromHeaders(headers = {}) {
const disposition = headers['content-disposition'] || headers['Content-Disposition'];
if (!disposition) return null;
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match) {
try {
return decodeURIComponent(utf8Match[1].trim());
} catch {
return utf8Match[1].trim();
}
}
const quotedMatch = disposition.match(/filename="([^"]+)"/i);
if (quotedMatch) return quotedMatch[1];
const plainMatch = disposition.match(/filename=([^;]+)/i);
return plainMatch ? plainMatch[1].trim() : null;
}
#extensionFromContentType(contentType = '') {
const ct = String(contentType).split(';')[0].trim().toLowerCase();
const map = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/bmp': 'bmp',
'image/webp': 'webp',
'image/heic': 'heic',
'image/heif': 'heif',
'application/pdf': 'pdf',
'text/plain': 'txt',
};
return map[ct] || null;
}
async inspectMessageFile(fileUrl) {
if (!fileUrl) throw new Error('fileUrl is required');
const res = await axios.head(fileUrl, {
headers: { Authorization: this.axios.defaults.headers.Authorization },
timeout: 30000,
validateStatus: (status) => status < 500,
});
if (res.status >= 400) {
throw new Error(`File inspect failed: HTTP ${res.status}`);
}
const contentType = res.headers['content-type']?.split(';')[0]?.trim()
|| 'application/octet-stream';
const fileName = this.#parseFilenameFromHeaders(res.headers)
|| `attachment.${this.#extensionFromContentType(contentType) || 'bin'}`;
return { fileName, contentType };
}
async downloadMessageFile(fileUrl, fileNameHint = null) {
if (!fileUrl) throw new Error('fileUrl is required');
let fileName = fileNameHint;
let contentType = 'application/octet-stream';
if (!fileName) {
try {
const inspected = await this.inspectMessageFile(fileUrl);
fileName = inspected.fileName;
contentType = inspected.contentType;
} catch (err) {
logger('webex:bot:downloadMessageFile', `HEAD failed, continuing with GET: ${err.message}`, 'warn');
fileName = 'attachment.bin';
}
}
const res = await axios.get(fileUrl, {
headers: { Authorization: this.axios.defaults.headers.Authorization },
responseType: 'arraybuffer',
timeout: 120000,
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
const headerType = res.headers['content-type']?.split(';')[0]?.trim();
if (headerType) contentType = headerType;
const headerName = this.#parseFilenameFromHeaders(res.headers);
if (headerName) fileName = headerName;
const buffer = Buffer.from(res.data);
logger('webex:bot:downloadMessageFile', `Downloaded ${fileName} (${buffer.length} bytes)`);
return { buffer, contentType, fileName };
}
// ──────────────────────────────────────────────
// Adaptive Card + person helpers (for approval flow)
// ──────────────────────────────────────────────

View file

@ -2,7 +2,7 @@
* src/server/adminAuth.js
*
* Small, dependency-free HTTP auth middleware for admin/report endpoints
* (/cleanup-test, /stale-workorders, /track-backfill, etc.).
* (/cleanup-test, /stale-workorders, /track-backfill, /create-wo-discovery, etc.).
*
* Accepts either:
* 1. `Authorization: Bearer <ADMIN_TOKEN>` header, or

View file

@ -19,6 +19,8 @@ import { verifyWebhook } from './webhookAuth.js';
import { requireAdmin } from './adminAuth.js';
import { pollActiveShipments, backfillShipmentsFromAllMappings } from '../services/shipmentTrackingService.js';
import { isShipmentTrackingEnabled } from '../integrations/fedex/client.js';
import { discoverWorkOrderCreateFields } from '../integrations/serviceChannel/client.js';
import { getWorkOrderCreateConfig } from '../config/workOrderCreateConfig.js';
// ────────────────────────────────────────────────────────────────────────────
// HTML escaping — used everywhere we interpolate SC-sourced data into HTML
@ -336,6 +338,34 @@ export function createApp({
res.send(html);
});
// ────────────────────────────────────────────────────────────────────────
// /create-wo-discovery — inspect recent SC WOs for /createWO defaults
// ────────────────────────────────────────────────────────────────────────
app.get('/create-wo-discovery', requireAdmin(), async (req, res) => {
const trade = req.query.trade ? String(req.query.trade) : getWorkOrderCreateConfig().tradeName;
const limit = req.query.limit ? Number(req.query.limit) : 25;
logger('create-wo-discovery', `Called trade=${trade} limit=${limit} by ${req.ip}`);
try {
const [discovery, config] = await Promise.all([
discoverWorkOrderCreateFields({ trade, limit }),
Promise.resolve(getWorkOrderCreateConfig()),
]);
res.json({
config,
discovery,
hint:
'Set SC_CREATE_CATEGORIES, SC_CREATE_PRIORITIES, SC_CREATE_DEFAULT_* from suggested values. ' +
'If create fails with error 903, set SC_CREATE_REQUIRE_ISSUELIST=true and IssueList env vars.',
});
} catch (err) {
logger('create-wo-discovery', `Failed: ${err.message}`, 'error');
res.status(502).json({ error: err.message });
}
});
// Stale Work Order Report (Phase 0) — protected admin endpoint
if (getStaleWorkOrdersReport) {
app.get('/stale-workorders', requireAdmin(), async (req, res) => {
@ -535,13 +565,23 @@ export function setupCron({ db, runSpaceCleanup, webex } = {}) {
}
if (db && webex && isShipmentTrackingEnabled()) {
const trackingCron = process.env.SHIPMENT_TRACKING_CRON || '0 0 8,10,12,14,16,18 * * *';
const defaultTrackingCron = '0 0 8,10,12,14,16,18 * * *';
const trackingCron = process.env.SHIPMENT_TRACKING_CRON || defaultTrackingCron;
const trackingTz = process.env.SHIPMENT_TRACKING_TIMEZONE || 'America/New_York';
if (process.env.SHIPMENT_TRACKING_CRON && trackingCron !== defaultTrackingCron) {
logger(
'cron',
`SHIPMENT_TRACKING_CRON override (${trackingCron}) — expected ${defaultTrackingCron} for 2-hour 8am6pm ET polling`,
'warn'
);
}
cron.schedule(
trackingCron,
() => {
logger('cron:shipmentTracking', 'Starting FedEx shipment status poll');
pollActiveShipments({ db, webex }).catch((err) => {
pollActiveShipments({ db, webex, consolidateRoomPosts: true }).catch((err) => {
logger('cron:shipmentTracking', `Unhandled: ${err.message}`, 'error');
});
},

View file

@ -9,6 +9,7 @@ import {
isFedExConfigured,
isShipmentTrackingEnabled,
trackByNumbers,
formatFedExStatusLine,
} from '../integrations/fedex/client.js';
import webexService from './webexService.js';
import {
@ -51,25 +52,24 @@ function isDelivered(statusCode, 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(' · ');
return formatFedExStatusLine(row);
}
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 formatFedExStatusLine(result);
}
function hasShipmentStatusChanged(row, 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) {
return newLabel !== row.lastPostedStatus;
}
return parts.join(' · ');
// Never posted to the WO room (e.g. backfill synced DB with lastPostedStatus NULL).
return true;
}
export function formatShipmentStatusMarkdown({
@ -77,6 +77,7 @@ export function formatShipmentStatusMarkdown({
statusDescription = null,
statusCode = null,
estimatedDelivery = null,
estimatedDeliveryStart = null,
deliveredAt = null,
ok = true,
error = null,
@ -86,16 +87,15 @@ export function formatShipmentStatusMarkdown({
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;
const summary = formatFedExStatusLine({
statusDescription,
statusCode,
estimatedDelivery,
estimatedDeliveryStart,
deliveredAt,
});
return `- **[${trackingNumber}](${url})** — ${summary}`;
}
function shipmentSnapshotFromTrackResult(row, result) {
@ -113,6 +113,7 @@ function shipmentSnapshotFromTrackResult(row, result) {
statusDescription: result.statusDescription,
statusCode: result.statusCode,
estimatedDelivery: result.estimatedDelivery,
estimatedDeliveryStart: result.estimatedDeliveryStart,
deliveredAt: delivered ? (result.deliveredAt || new Date().toISOString()) : null,
ok: true,
};
@ -167,7 +168,10 @@ async function applyBackfillTrackResult(db, row, result) {
estimatedDelivery: result.estimatedDelivery,
deliveredAt,
lastCheckedAt: now,
lastPostedStatus: statusLabel,
// 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,
});
if (delivered) {
@ -368,10 +372,32 @@ 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
// waiting for the next cron window.
try {
const rows = [];
for (const trackingNumber of newlyRegistered) {
const row = await dbGetShipment(db, workOrderId, trackingNumber);
if (row) rows.push(row);
}
if (rows.length) {
const results = await trackByNumbers(rows.map((r) => r.trackingNumber));
for (const row of rows) {
const result = results.get(row.trackingNumber) || {
ok: false,
error: 'Missing from batch response',
};
await applyTrackResult(db, row, result, webex, woNumber ? new Map([[workOrderId, woNumber]]) : null);
}
}
} catch (err) {
logger('shipmentTracking', `Initial poll failed for WO ${workOrderId}: ${err.message}`, 'warn');
}
return { registered: newlyRegistered.length, trackingNumbers: newlyRegistered };
}
async function applyTrackResult(db, row, result, webex, woNumberByWoId) {
async function applyTrackResult(db, row, result, webex, woNumberByWoId, { suppressRoomPosts = false } = {}) {
const now = new Date().toISOString();
if (!result.ok) {
@ -394,7 +420,7 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId) {
const statusLabel = formatStatusLabel(result);
const delivered = isDelivered(result.statusCode, result.statusDescription);
const deliveredAt = delivered ? (result.deliveredAt || now) : null;
const statusChanged = statusLabel !== row.lastPostedStatus;
const statusChanged = hasShipmentStatusChanged(row, result);
const newlyDelivered = delivered && !row.deliveredAt;
await dbUpdateShipment(db, row.trackingNumber, row.workOrderId, {
@ -406,9 +432,21 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId) {
lastPostedStatus: statusChanged ? statusLabel : row.lastPostedStatus,
});
if (statusChanged) {
const sender = getSender(webex);
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}` +
@ -416,9 +454,26 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId) {
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) {
@ -430,9 +485,8 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId) {
if (delivered) {
const remaining = await dbCountActiveForWo(db, row.workOrderId);
if (remaining === 0) {
if (remaining === 0 && !suppressRoomPosts) {
const sender = getSender(webex);
const woNum = woNumberByWoId?.get(row.workOrderId) || row.workOrderId;
try {
await sender.sendMarkdown(
row.roomId,
@ -444,10 +498,52 @@ async function applyTrackResult(db, row, result, webex, woNumberByWoId) {
}
}
return { updated: statusChanged, delivered };
return { updated: statusChanged, delivered, pendingPost };
}
export async function pollActiveShipments({ db, webex, workOrderId = null } = {}) {
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}.`);
} catch (err) {
logger('shipmentTracking', `All-delivered post failed for WO ${woNum}: ${err.message}`, 'warn');
}
}
}
}
export async function pollActiveShipments({
db,
webex,
workOrderId = null,
suppressRoomPosts = false,
consolidateRoomPosts = false,
} = {}) {
if (!isShipmentTrackingEnabled() || !isFedExConfigured()) {
return { checked: 0, updated: 0, delivered: 0, shipments: [], skipped: true };
}
@ -459,6 +555,8 @@ export async function pollActiveShipments({ db, webex, workOrderId = null } = {}
let updated = 0;
let delivered = 0;
const shipments = [];
const consolidatedPosts = new Map();
const suppressPosts = suppressRoomPosts || consolidateRoomPosts;
for (let i = 0; i < active.length; i += BATCH_SIZE) {
const batch = active.slice(i, i + BATCH_SIZE);
@ -484,13 +582,24 @@ export async function pollActiveShipments({ db, webex, workOrderId = null } = {}
ok: false,
error: 'Missing from batch response',
};
const outcome = await applyTrackResult(db, row, result, webex, null);
const outcome = await applyTrackResult(db, row, result, webex, null, {
suppressRoomPosts: suppressPosts,
});
if (outcome.updated) updated++;
if (outcome.delivered) delivered++;
if (consolidateRoomPosts && outcome.pendingPost) {
const list = consolidatedPosts.get(outcome.pendingPost.roomId) || [];
list.push(outcome.pendingPost);
consolidatedPosts.set(outcome.pendingPost.roomId, list);
}
shipments.push(shipmentSnapshotFromTrackResult(row, result));
}
}
if (consolidateRoomPosts && consolidatedPosts.size) {
await flushConsolidatedRoomPosts(db, webex, consolidatedPosts);
}
logger(
'shipmentTracking',
`Poll complete: checked=${active.length}, updated=${updated}, delivered=${delivered}` +
@ -501,7 +610,7 @@ export async function pollActiveShipments({ db, webex, workOrderId = null } = {}
}
export async function pollShipmentsForWorkOrder({ db, webex, workOrderId }) {
return pollActiveShipments({ db, webex, workOrderId });
return pollActiveShipments({ db, webex, workOrderId, suppressRoomPosts: true });
}
/**

View file

@ -37,6 +37,10 @@ import approvalService from './approvalService.js';
import invoiceApprovalService from './invoiceApprovalService.js';
import shipmentTrackingService from './shipmentTrackingService.js';
import attachmentService from './attachmentService.js';
import {
shouldSuppressAttachmentPost,
shouldSuppressWebhookNote,
} from '../db/uploadEchoSuppress.js';
export function createWebhookProcessor(options = {}) {
const {
@ -156,6 +160,7 @@ export function createWebhookProcessor(options = {}) {
const { payload: p, startTime: itemStart } = queue.shift();
let text = '';
let suppressRoomPost = false;
if (p.EventType === 'WorkOrderCreated') {
let summaryResult;
@ -184,10 +189,15 @@ export function createWebhookProcessor(options = {}) {
`**Description:** ${summaryResult?.summary || p.Object.Description?.substring(0, 300) || 'No description'}\n\n`;
} else if (p.EventType === 'WorkOrderNoteAdded') {
const note = p.Object.Notes?.[0] || {};
if (await shouldSuppressWebhookNote(db, workOrderId, note)) {
suppressRoomPost = true;
logger('webhookProcessor', `Suppressed note echo for WO ${workOrderId}`);
} else {
text =
`### [Note Added](https://www.servicechannel.com/sc/wo/Workorders/index?id=${p.Object.Id})\n\n` +
`${note.NoteData || 'No note content'}${note.CreatedBy || 'Unknown'}\n\n` +
`**Status:** ${p.Object.Status?.Primary || 'N/A'} | ${p.Object.Status?.Extended || 'N/A'}`;
}
} else {
// Generic fallback for other events
text =
@ -198,6 +208,7 @@ export function createWebhookProcessor(options = {}) {
`**Updated:** ${new Date().toISOString()}`;
}
if (!suppressRoomPost) {
if (!text.trim()) {
text = `Webhook received for Work Order ${workOrderId} (${p.EventType || 'unknown event'}).`;
}
@ -209,11 +220,15 @@ export function createWebhookProcessor(options = {}) {
const details = postErr.response?.data ? JSON.stringify(postErr.response.data) : postErr.message;
logger('webhookProcessor', `Post failed for ${workOrderId}: ${details}`, 'error');
}
}
// Auto-post attachments referenced in this note
if (p.EventType === 'WorkOrderNoteAdded') {
const note = p.Object.Notes?.[0] || {};
if (note.AttachmentIds?.length) {
const suppressAttachments = suppressRoomPost
|| await shouldSuppressAttachmentPost(db, workOrderId, note.AttachmentIds);
if (!suppressAttachments) {
scheduleAttachmentPost({
roomId,
workOrderId,
@ -222,6 +237,7 @@ export function createWebhookProcessor(options = {}) {
});
}
}
}
// Auto-remove stale approval cards when resolved in ServiceChannel
const noteDataForApproval = (p.EventType === 'WorkOrderNoteAdded' && p.Object.Notes?.[0]?.NoteData) || null;

View file

@ -0,0 +1,317 @@
/**
* Adaptive Card flow for /createWO create ServiceChannel work orders from DM.
*/
import { logger } from '../utils/logger.js';
import {
buildCreateWorkOrderPayload,
createWorkOrder,
resolveCreateContractInfo,
} from '../integrations/serviceChannel/client.js';
import {
getWorkOrderCreateConfig,
isPersonAllowedToCreateWorkOrder,
isWorkOrderCreateEnabled,
} from '../config/workOrderCreateConfig.js';
import webexService from './webexService.js';
const STORE_RE = /^\d{1,6}$/;
function _choiceSet(id, label, choices, defaultValue) {
const items = (choices || []).map((value) => ({
title: value,
value,
}));
if (!items.length) {
return {
type: 'Input.Text',
id,
label,
value: defaultValue || '',
placeholder: label,
};
}
return {
type: 'Input.ChoiceSet',
id,
label,
value: defaultValue || items[0].value,
choices: items,
style: 'compact',
};
}
export function buildCreateWorkOrderAdaptiveCard(config = getWorkOrderCreateConfig()) {
const body = [
{
type: 'TextBlock',
text: 'Create ServiceChannel Work Order',
size: 'medium',
weight: 'bolder',
},
{
type: 'TextBlock',
text:
`**${config.tradeName}** · **${config.providerName}** · ` +
`NTE **$${Number(config.defaultNte).toFixed(2)}**`,
isSubtle: true,
wrap: true,
},
{
type: 'Input.Text',
id: 'storeId',
label: 'Store number (short form OK — normalized to 6 digits)',
placeholder: 'e.g. 305 or 000305',
isRequired: true,
maxLength: 6,
},
{
type: 'Input.Text',
id: 'description',
label: 'Problem description',
placeholder: 'Describe the AV issue…',
isMultiline: true,
isRequired: true,
},
_choiceSet('category', 'Category', config.categories, config.defaultCategory),
_choiceSet('priority', 'Priority', config.priorities, config.defaultPriority),
];
if (config.problemCodes.length) {
body.push(_choiceSet('problemCode', 'Problem code', config.problemCodes, config.problemCodes[0]));
} else {
body.push({
type: 'Input.Text',
id: 'problemCode',
label: config.requireIssueList ? 'Problem code (required)' : 'Problem code (optional)',
placeholder: 'e.g. Display not working',
isRequired: config.requireIssueList,
});
}
body.push(
{
type: 'Input.Number',
id: 'nte',
label: 'NTE (max spend)',
value: config.defaultNte,
min: 0,
},
{
type: 'TextBlock',
text: 'After submit, ServChan creates the WO in ServiceChannel. The Webex group space appears automatically when the WorkOrderCreated webhook arrives.',
size: 'small',
isSubtle: true,
wrap: true,
}
);
return {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.3',
body,
actions: [
{
type: 'Action.Submit',
title: 'Create work order',
style: 'positive',
data: { action: 'createWorkOrder' },
},
{
type: 'Action.Submit',
title: 'Cancel',
data: { action: 'dismissCreateWoCard' },
},
],
};
}
async function _removeCardMessage(bot, action) {
const messageId = action?.messageId;
if (!messageId) return;
if (typeof bot?.censor === 'function') {
try {
await bot.censor(messageId);
return;
} catch (err) {
logger('woCreate:submit', `bot.censor failed for ${messageId}: ${err.message}`, 'warn');
}
}
try {
const botClientMod = await import('../integrations/webex/botClient.js');
await botClientMod.default.deleteMessage(messageId);
} catch (err) {
logger('woCreate:submit', `Could not delete card ${messageId}: ${err.message}`, 'warn');
}
}
export async function postCreateWorkOrderCard(bot, trigger) {
if (!isWorkOrderCreateEnabled()) {
await bot.say('Work order creation is disabled on this bot (`SC_CREATE_ENABLED=false`).');
return;
}
const personId = trigger?.message?.personId || trigger?.personId;
const config = getWorkOrderCreateConfig();
if (!isPersonAllowedToCreateWorkOrder(personId, config)) {
await bot.say('You are not authorized to create work orders with this bot.');
return;
}
const roomId = trigger?.message?.roomId;
if (!roomId) {
await bot.say('Could not determine the conversation to post the form.');
return;
}
const card = buildCreateWorkOrderAdaptiveCard(config);
const fallback =
'Create ServiceChannel work order — open in Webex to fill out store, description, category, and priority.';
const sender = typeof bot?.sendAdaptiveCard === 'function' ? bot : webexService;
await sender.sendAdaptiveCard(roomId, card, fallback);
logger('woCreate', `Posted create-WO card to room ${roomId} for person ${personId || 'unknown'}`);
}
export async function handleCreateWorkOrderSubmit(bot, trigger) {
const action = trigger?.attachmentAction;
if (!action?.inputs) return;
const actionType = action.inputs.action;
if (actionType === 'dismissCreateWoCard') {
await _removeCardMessage(bot, action);
try {
await bot.say('Work order creation cancelled.');
} catch (_) {}
return;
}
if (actionType !== 'createWorkOrder') return;
if (!isWorkOrderCreateEnabled()) {
try {
await bot.say('Work order creation is disabled on this bot.');
} catch (_) {}
return;
}
if (!isPersonAllowedToCreateWorkOrder(action.personId)) {
try {
await bot.say('You are not authorized to create work orders with this bot.');
} catch (_) {}
return;
}
const inputs = action.inputs;
const storeId = String(inputs.storeId ?? '').trim();
const description = String(inputs.description ?? '').trim();
const category = String(inputs.category ?? '').trim();
const priority = String(inputs.priority ?? '').trim();
const problemCode = String(inputs.problemCode ?? '').trim();
const nte = Number(inputs.nte);
const config = getWorkOrderCreateConfig();
if (!STORE_RE.test(storeId)) {
try {
await bot.say('Invalid store number — use 16 digits (e.g. **305** or **000305**).');
} catch (_) {}
return;
}
if (!description) {
try {
await bot.say('Description is required.');
} catch (_) {}
return;
}
if (!category || !priority) {
try {
await bot.say('Category and priority are required.');
} catch (_) {}
return;
}
if (config.requireIssueList && !problemCode) {
try {
await bot.say('Problem code is required for this tenant (`SC_CREATE_REQUIRE_ISSUELIST=true`).');
} catch (_) {}
return;
}
let submitter = 'Webex user';
try {
const botClientMod = await import('../integrations/webex/botClient.js');
const details = await botClientMod.default.getPersonDetails(action.personId);
submitter = details?.displayName || details?.emails?.[0] || submitter;
} catch (err) {
logger('woCreate:submit', `Could not resolve submitter: ${err.message}`, 'warn');
}
try {
const contract = await resolveCreateContractInfo(storeId, {
padLength: config.storePadLength,
preferredTrade: config.tradeName,
});
const payload = buildCreateWorkOrderPayload({
storeId: contract.storeId,
locationId: contract.locationId,
description: `${description}\n\n(Created via ServChan by ${submitter})`,
category,
priority,
nte: Number.isFinite(nte) ? nte : config.defaultNte,
tradeName: contract.tradeName,
providerId: config.providerId,
problemCode: problemCode || null,
issueRequestInfo: config.issueRequestInfo,
});
const created = await createWorkOrder(payload);
const woId = created?.Id ?? created?.id;
const woNumber = created?.Number ?? created?.WorkorderNumber ?? woId;
const scLink = woId
? `https://www.servicechannel.com/sc/wo/Workorders/index?id=${woId}`
: 'https://www.servicechannel.com/sc/wo/Workorders';
await _removeCardMessage(bot, action);
await bot.say({
markdown:
`✅ **Work order created** · [WO-${woNumber}](${scLink})\n\n` +
`- **Store:** ${contract.storeId}${contract.name ? ` (${contract.name})` : ''}\n` +
`- **Trade / Provider:** ${contract.tradeName} · ${config.providerName}\n` +
`- **Category / Priority:** ${category} · ${priority}\n\n` +
`The ServChan group space will appear shortly when ServiceChannel sends the **WorkOrderCreated** webhook.`,
});
logger(
'woCreate:submit',
`Created WO ${woId || woNumber} for store ${contract.storeId} (${contract.tradeName}, ${config.providerName}) by ${submitter}`
);
} catch (err) {
const msg = err.message || String(err);
const isTokenError = msg.includes('token fetch failed');
const reply = isTokenError
? `❌ **ServiceChannel login failed** — check SC credentials and restart ServChan.`
: `❌ **Failed to create work order:** ${msg}\n\nVerify store, category, priority, and trade/provider settings. Use \`GET /create-wo-discovery\` (admin) to inspect tenant defaults.`;
try {
await bot.say({ markdown: reply });
} catch (_) {}
logger('woCreate:submit', `Create failed for store ${storeId}: ${msg}`, 'error');
}
}
export default {
buildCreateWorkOrderAdaptiveCard,
postCreateWorkOrderCard,
handleCreateWorkOrderSubmit,
};

View file

@ -0,0 +1,134 @@
/**
* Upload Webex message files to a ServiceChannel work order.
*/
import { uploadWorkOrderAttachment } from '../integrations/serviceChannel/attachments.js';
import { addWorkOrderNote } from '../integrations/serviceChannel/client.js';
import { tryClaimBotMessage } from '../db/botMessageDedup.js';
import {
markAttachmentPosted,
registerUploadEchoSuppress,
} from '../db/uploadEchoSuppress.js';
import { resolveDisplayName, resolveWoRoomContext } from '../utils/woRoomContext.js';
import { prepareScUpload } from '../utils/prepareScUpload.js';
import { logger } from '../utils/logger.js';
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
function getMaxUploadBytes() {
const raw = process.env.SC_UPLOAD_MAX_BYTES;
if (raw == null || raw === '') return DEFAULT_MAX_BYTES;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_BYTES;
}
async function resolveFileUrls(botClient, trigger) {
let files = trigger.message?.files;
if (Array.isArray(files) && files.length > 0) return files;
const messageId = trigger.message?.id;
if (!messageId) return [];
try {
const msg = await botClient.getMessage(messageId);
files = msg?.files;
return Array.isArray(files) ? files : [];
} catch (err) {
logger('wo:upload', `Failed to refetch message ${messageId}: ${err.message}`, 'warn');
return [];
}
}
/**
* @returns {Promise<
* | { ok: true, uploaded: Array<{ name: string, scAttachmentId: number|null }>, captionPosted: boolean }
* | { ok: false, error: string }
* >}
*/
export async function uploadFilesFromTrigger({ db, botClient, trigger, caption = '' }) {
const roomId = trigger.message?.roomId;
const isGroup = trigger.message?.roomType === 'group';
const ctx = await resolveWoRoomContext({ db, botClient, roomId, isGroup });
if (!ctx.ok) {
return { ok: false, error: ctx.error };
}
const messageId = trigger.message?.id;
try {
const claimed = await tryClaimBotMessage(db, messageId, 'attach');
if (!claimed) {
logger('wo:upload', `Skipped duplicate /attach for message ${messageId}`, 'info');
return { ok: false, error: null };
}
} catch (err) {
logger('wo:upload', `Dedup check failed: ${err.message}`, 'warn');
}
const fileUrls = await resolveFileUrls(botClient, trigger);
if (!fileUrls.length) {
return {
ok: false,
error: 'Attach a file to your message. Usage: `/attach [optional caption]` or `/upload [optional caption]` (WO space only).',
};
}
const maxBytes = getMaxUploadBytes();
const uploaded = [];
for (let i = 0; i < fileUrls.length; i++) {
const fileUrl = fileUrls[i];
const { buffer, contentType, fileName } = await botClient.downloadMessageFile(fileUrl);
if (buffer.length > maxBytes) {
const limitMb = Math.round(maxBytes / (1024 * 1024));
throw new Error(`File ${fileName} exceeds the ${limitMb} MB upload limit.`);
}
const prepared = await prepareScUpload(buffer, fileName, contentType);
await registerUploadEchoSuppress(db, ctx.workOrderId, {
fileName: prepared.fileName,
});
const result = await uploadWorkOrderAttachment(
ctx.workOrderId,
prepared.buffer,
prepared.fileName,
prepared.contentType
);
uploaded.push({
name: result.name || prepared.fileName,
scAttachmentId: result.id ?? null,
});
if (result.id != null) {
await registerUploadEchoSuppress(db, ctx.workOrderId, {
attachmentId: result.id,
fileName: result.name || prepared.fileName,
});
await markAttachmentPosted(db, ctx.workOrderId, result.id);
}
}
let captionPosted = false;
const trimmedCaption = String(caption || '').trim();
if (trimmedCaption) {
const displayName = await resolveDisplayName(botClient, trigger.personId);
const scNote = `${displayName}: ${trimmedCaption}`;
await registerUploadEchoSuppress(db, ctx.workOrderId, { noteText: scNote });
await addWorkOrderNote(ctx.workOrderId, scNote);
captionPosted = true;
logger('wo:upload', `Posted caption note to SC WO ${ctx.workOrderId}`);
}
logger(
'wo:upload',
`Uploaded ${uploaded.length} file(s) to SC WO ${ctx.workOrderId} by ${trigger.personId || 'unknown'}`
);
return { ok: true, uploaded, captionPosted };
}
export default { uploadFilesFromTrigger };

View file

@ -0,0 +1,33 @@
/**
* Normalize inbound Webex file bytes before uploading to ServiceChannel.
* Converts HEIC/HEIF to JPEG (same as outbound Webex posts).
*/
import convert from 'heic-convert';
import { getContentTypeFromFilename } from '../integrations/serviceChannel/attachments.js';
import { needsJpegConversion, jpegFileName } from './prepareWebexAttachment.js';
import { logger } from './logger.js';
export async function prepareScUpload(buffer, fileName, contentType) {
const resolvedType = contentType || getContentTypeFromFilename(fileName);
if (needsJpegConversion(fileName, resolvedType)) {
const jpegBuffer = Buffer.from(await convert({
buffer,
format: 'JPEG',
quality: 0.92,
}));
const outName = jpegFileName(fileName);
logger('upload:convert', `Converted ${fileName}${outName} (${jpegBuffer.length} bytes)`);
return { buffer: jpegBuffer, fileName: outName, contentType: 'image/jpeg' };
}
return {
buffer,
fileName,
contentType: resolvedType === 'application/octet-stream'
? getContentTypeFromFilename(fileName)
: resolvedType,
};
}