From 0fc3eb38fa34955ba221893eece24e412ab72492 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Wed, 26 Aug 2026 17:22:21 -0400 Subject: [PATCH] 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 --- .env.example | 22 ++ README.md | 26 +- index.js | 38 +++ src/bot/index.js | 17 + src/commands/help.js | 2 + src/commands/woAddNote.js | 12 + src/commands/woAttach.js | 42 +++ src/commands/woCreate.js | 27 ++ src/commands/woTrackStatus.js | 6 +- src/commands/woTrackTest.js | 13 +- src/config/workOrderCreateConfig.js | 81 +++++ src/db/botMessageDedup.js | 20 ++ src/db/uploadEchoSuppress.js | 168 ++++++++++ src/integrations/fedex/client.js | 139 ++++++-- .../serviceChannel/attachments.js | 57 +++- src/integrations/serviceChannel/client.js | 208 ++++++++++++ src/integrations/webex/botClient.js | 103 ++++++ src/server/adminAuth.js | 2 +- src/server/app.js | 44 ++- src/services/shipmentTrackingService.js | 199 ++++++++--- src/services/webhookProcessor.js | 54 +-- src/services/workOrderCreateService.js | 317 ++++++++++++++++++ src/services/workOrderUploadService.js | 134 ++++++++ src/utils/prepareScUpload.js | 33 ++ 24 files changed, 1649 insertions(+), 115 deletions(-) create mode 100644 src/commands/woAttach.js create mode 100644 src/commands/woCreate.js create mode 100644 src/config/workOrderCreateConfig.js create mode 100644 src/db/botMessageDedup.js create mode 100644 src/db/uploadEchoSuppress.js create mode 100644 src/services/workOrderCreateService.js create mode 100644 src/services/workOrderUploadService.js create mode 100644 src/utils/prepareScUpload.js diff --git a/.env.example b/.env.example index a4a4c97..ff37818 100644 --- a/.env.example +++ b/.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 diff --git a/README.md b/README.md index c0bf822..1f74868 100644 --- a/README.md +++ b/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. diff --git a/index.js b/index.js index 700e195..d8f00ee 100644 --- a/index.js +++ b/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}`); }); diff --git a/src/bot/index.js b/src/bot/index.js index 64584c4..a505d8f 100644 --- a/src/bot/index.js +++ b/src/bot/index.js @@ -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; diff --git a/src/commands/help.js b/src/commands/help.js index 1b7e8d9..9c9e36b 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -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 ** — status of any work order\n`; text += `- **/woHistory ** — History of AV issues.\n`; text += `- **/woAttachments ** — download attachments for any work order\n`; diff --git a/src/commands/woAddNote.js b/src/commands/woAddNote.js index 4036748..951019b 100644 --- a/src/commands/woAddNote.js +++ b/src/commands/woAddNote.js @@ -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}`; diff --git a/src/commands/woAttach.js b/src/commands/woAttach.js new file mode 100644 index 0000000..cd2e0ac --- /dev/null +++ b/src/commands/woAttach.js @@ -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}`); + } +} diff --git a/src/commands/woCreate.js b/src/commands/woCreate.js new file mode 100644 index 0000000..55b5416 --- /dev/null +++ b/src/commands/woCreate.js @@ -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); +} diff --git a/src/commands/woTrackStatus.js b/src/commands/woTrackStatus.js index 21819dc..b37061c 100644 --- a/src/commands/woTrackStatus.js +++ b/src/commands/woTrackStatus.js @@ -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` : ''}_`, }); } diff --git a/src/commands/woTrackTest.js b/src/commands/woTrackTest.js index d9fb052..8a5f626 100644 --- a/src/commands/woTrackTest.js +++ b/src/commands/woTrackTest.js @@ -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) { diff --git a/src/config/workOrderCreateConfig.js b/src/config/workOrderCreateConfig.js new file mode 100644 index 0000000..d02ffef --- /dev/null +++ b/src/config/workOrderCreateConfig.js @@ -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()); +} diff --git a/src/db/botMessageDedup.js b/src/db/botMessageDedup.js new file mode 100644 index 0000000..53ff369 --- /dev/null +++ b/src/db/botMessageDedup.js @@ -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); + } + ); + }); +} diff --git a/src/db/uploadEchoSuppress.js b/src/db/uploadEchoSuppress.js new file mode 100644 index 0000000..811180c --- /dev/null +++ b/src/db/uploadEchoSuppress.js @@ -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; +} diff --git a/src/integrations/fedex/client.js b/src/integrations/fedex/client.js index 8dcc6b1..5c64a2f 100644 --- a/src/integrations/fedex/client.js +++ b/src/integrations/fedex/client.js @@ -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 }; diff --git a/src/integrations/serviceChannel/attachments.js b/src/integrations/serviceChannel/attachments.js index ea8e9f9..6e7ee1d 100644 --- a/src/integrations/serviceChannel/attachments.js +++ b/src/integrations/serviceChannel/attachments.js @@ -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); + } +} diff --git a/src/integrations/serviceChannel/client.js b/src/integrations/serviceChannel/client.js index ec5c145..4bcf0f4 100644 --- a/src/integrations/serviceChannel/client.js +++ b/src/integrations/serviceChannel/client.js @@ -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, + }, + }; +} diff --git a/src/integrations/webex/botClient.js b/src/integrations/webex/botClient.js index 0708161..a4cfdd0 100644 --- a/src/integrations/webex/botClient.js +++ b/src/integrations/webex/botClient.js @@ -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) // ────────────────────────────────────────────── diff --git a/src/server/adminAuth.js b/src/server/adminAuth.js index 62ce95e..387e361 100644 --- a/src/server/adminAuth.js +++ b/src/server/adminAuth.js @@ -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 ` header, or diff --git a/src/server/app.js b/src/server/app.js index 5e0046a..cb7477e 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -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'); }); }, diff --git a/src/services/shipmentTrackingService.js b/src/services/shipmentTrackingService.js index 648a658..96d6e12 100644 --- a/src/services/shipmentTrackingService.js +++ b/src/services/shipmentTrackingService.js @@ -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,19 +432,48 @@ 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; - const md = - `**FedEx update** · WO-${woNum}\n\n` + - `Tracking **[${row.trackingNumber}](${fedExTrackUrl(row.trackingNumber)})**: ${statusLabel}` + - (delivered ? '\n\nPackage delivered.' : ''); + const woNum = woNumberByWoId?.get(row.workOrderId) || row.workOrderId; + let pendingPost = null; - try { - await sender.sendMarkdown(row.roomId, md); - } catch (err) { - logger('shipmentTracking', `Update post failed for ${row.trackingNumber}: ${err.message}`, 'warn'); + if (statusChanged) { + pendingPost = { + roomId: row.roomId, + workOrderId: row.workOrderId, + trackingNumber: row.trackingNumber, + statusLabel, + delivered, + woNum, + }; + + if (!suppressRoomPosts) { + const sender = getSender(webex); + const md = + `**FedEx update** · WO-${woNum}\n\n` + + `Tracking **[${row.trackingNumber}](${fedExTrackUrl(row.trackingNumber)})**: ${statusLabel}` + + (delivered ? '\n\nPackage delivered.' : ''); + + try { + await sender.sendMarkdown(row.roomId, md); + logger( + 'shipmentTracking', + `Posted status update for ${row.trackingNumber} (WO ${row.workOrderId}): ${statusLabel}` + ); + } catch (err) { + logger('shipmentTracking', `Update post failed for ${row.trackingNumber}: ${err.message}`, 'warn'); + } + } else { + logger( + 'shipmentTracking', + `Suppressed WO post for ${row.trackingNumber} (WO ${row.workOrderId}): ${statusLabel}`, + 'info' + ); } + } else { + logger( + 'shipmentTracking', + `No WO post for ${row.trackingNumber} (WO ${row.workOrderId}): unchanged (${statusLabel})`, + 'info' + ); } if (newlyDelivered) { @@ -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 }); } /** diff --git a/src/services/webhookProcessor.js b/src/services/webhookProcessor.js index 8fb99d7..b232b63 100644 --- a/src/services/webhookProcessor.js +++ b/src/services/webhookProcessor.js @@ -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] || {}; - 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'}`; + 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,28 +208,34 @@ export function createWebhookProcessor(options = {}) { `**Updated:** ${new Date().toISOString()}`; } - if (!text.trim()) { - text = `Webhook received for Work Order ${workOrderId} (${p.EventType || 'unknown event'}).`; - } + if (!suppressRoomPost) { + if (!text.trim()) { + text = `Webhook received for Work Order ${workOrderId} (${p.EventType || 'unknown event'}).`; + } - try { - await webex.sendMarkdown(roomId, text); - logger('webhookProcessor', `Posted for ${workOrderId} (${Date.now() - itemStart}ms)`); - } catch (postErr) { - const details = postErr.response?.data ? JSON.stringify(postErr.response.data) : postErr.message; - logger('webhookProcessor', `Post failed for ${workOrderId}: ${details}`, 'error'); + try { + await webex.sendMarkdown(roomId, text); + logger('webhookProcessor', `Posted for ${workOrderId} (${Date.now() - itemStart}ms)`); + } catch (postErr) { + 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) { - scheduleAttachmentPost({ - roomId, - workOrderId, - woNumber: p.Object.Number, - attachmentIds: note.AttachmentIds, - }); + const suppressAttachments = suppressRoomPost + || await shouldSuppressAttachmentPost(db, workOrderId, note.AttachmentIds); + if (!suppressAttachments) { + scheduleAttachmentPost({ + roomId, + workOrderId, + woNumber: p.Object.Number, + attachmentIds: note.AttachmentIds, + }); + } } } diff --git a/src/services/workOrderCreateService.js b/src/services/workOrderCreateService.js new file mode 100644 index 0000000..b3d0f34 --- /dev/null +++ b/src/services/workOrderCreateService.js @@ -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, +}; diff --git a/src/services/workOrderUploadService.js b/src/services/workOrderUploadService.js new file mode 100644 index 0000000..3cdcc48 --- /dev/null +++ b/src/services/workOrderUploadService.js @@ -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 }; diff --git a/src/utils/prepareScUpload.js b/src/utils/prepareScUpload.js new file mode 100644 index 0000000..6d9bb04 --- /dev/null +++ b/src/utils/prepareScUpload.js @@ -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, + }; +}