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:
parent
d6549c07f8
commit
0fc3eb38fa
24 changed files with 1649 additions and 115 deletions
22
.env.example
22
.env.example
|
|
@ -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
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
38
index.js
38
index.js
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, 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 += `- **/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`;
|
||||
|
|
|
|||
|
|
@ -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
42
src/commands/woAttach.js
Normal 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
27
src/commands/woCreate.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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` : ''}_`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
81
src/config/workOrderCreateConfig.js
Normal file
81
src/config/workOrderCreateConfig.js
Normal 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
20
src/db/botMessageDedup.js
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
168
src/db/uploadEchoSuppress.js
Normal file
168
src/db/uploadEchoSuppress.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 8am–6pm 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');
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
317
src/services/workOrderCreateService.js
Normal file
317
src/services/workOrderCreateService.js
Normal 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 1–6 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,
|
||||
};
|
||||
134
src/services/workOrderUploadService.js
Normal file
134
src/services/workOrderUploadService.js
Normal 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 };
|
||||
33
src/utils/prepareScUpload.js
Normal file
33
src/utils/prepareScUpload.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue