servchan/index.js
jmcqueen 0fc3eb38fa 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>
2026-08-26 17:22:21 -04:00

257 lines
No EOL
11 KiB
JavaScript

import 'dotenv/config';
import sqlite3 from 'sqlite3';
import path from 'node:path';
import fs from 'node:fs';
import { runSpaceCleanup } from './src/services/spaceCleanupService.js';
import { createWebhookProcessor } from './src/services/webhookProcessor.js';
import { initializeBot, stopBot } from './src/bot/index.js';
import { createApp, setupCron } from './src/server/app.js';
import { summarizeTicketDescription } from './src/integrations/xai/client.js';
import { WebexService } from './src/services/webexService.js';
import { getStaleWorkOrdersReport } from './src/services/staleWorkOrderReportService.js';
import { logTrackingBootStatus } from './src/services/shipmentTrackingService.js';
// ────────────────────────────────────────────────────────────────────────────────
// CONFIGURATION
// ────────────────────────────────────────────────────────────────────────────────
// Non-secret config only (server name/port, etc.). Secrets come exclusively from env.
// See src/config/index.js and src/config/secrets.js.
import nonSecretConfig from './src/config/index.js';
import { loadSecrets } from './src/config/secrets.js';
import { getDbPath } from './src/db/path.js';
import { ensureLogDir } from './src/utils/logPath.js';
import { getCollabSupportBase } from './src/integrations/collabSupport/client.js';
const secrets = loadSecrets();
const PORT = process.env.PORT || nonSecretConfig.server?.port || 1458;
// Use the shared resolver so index.js, src/db/path.js, and every downstream
// module agree on which file to open. Previously the fallback here (bot.db)
// disagreed with .env.example (webex_sc_mappings.db), which meant a missing
// DB_PATH env var would silently spawn a brand-new empty database.
const DB_PATH = getDbPath();
// Ensure runtime directories exist early. Log dir goes through the shared
// resolver (LOG_DIR env, default ./logs) so mounts, cleanup, and every logger
// call agree on one location.
const dataDir = path.dirname(DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const logsDir = ensureLogDir();
console.log(`[boot] Logs → ${logsDir}`);
const csBase = getCollabSupportBase();
if (csBase) {
console.log(`[boot] CollabSupport → ${csBase}${process.env.CS_API_BASE_INTERNAL ? ' (internal)' : ''}`);
}
// Build webexConfig for the Framework:
// - Token (and baseUrl) ALWAYS come from secrets/env (never baked into config.json or image).
// - Other fields (name, email, wbx_base_url) can still come from a (clean) config.json for compatibility.
const baseWebex = nonSecretConfig.auth?.webex || nonSecretConfig.webex || {};
const webexConfig = {
...baseWebex,
token: secrets.webex.token, // force from env
baseUrl: secrets.webex.baseUrl || baseWebex.wbx_base_url || baseWebex.baseUrl || 'https://webexapis.com/v1',
wbx_base_url: baseWebex.wbx_base_url || secrets.webex.baseUrl || 'https://webexapis.com/v1',
};
// ────────────────────────────────────────────────────────────────────────────────
// SQLite Database
// (Production DB file location is sacred — see REFACTOR-LOG.md)
const db = new sqlite3.Database(DB_PATH);
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS mappings (
workOrderId INTEGER PRIMARY KEY,
roomId TEXT UNIQUE NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS posted_attachments (
workOrderId INTEGER NOT NULL,
attachmentId INTEGER NOT NULL,
postedAt TEXT NOT NULL,
PRIMARY KEY (workOrderId, attachmentId)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS pending_approval_cards (
workOrderId INTEGER PRIMARY KEY,
roomId TEXT NOT NULL,
messageId TEXT NOT NULL,
proposalId INTEGER,
postedAt TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS pending_invoice_approval_cards (
workOrderId INTEGER PRIMARY KEY,
roomId TEXT NOT NULL,
messageId TEXT NOT NULL,
invoiceId INTEGER,
postedAt TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS space_cleanup_digest (
id INTEGER PRIMARY KEY CHECK (id = 1),
lastPostedAt TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS wo_closeouts (
workOrderId INTEGER PRIMARY KEY,
confirmedAt TEXT NOT NULL,
confirmedBy TEXT NOT NULL,
scStatusUpdated INTEGER NOT NULL DEFAULT 0,
noteText TEXT
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS shipment_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workOrderId INTEGER NOT NULL,
roomId TEXT NOT NULL,
carrier TEXT NOT NULL DEFAULT 'FEDEX',
trackingNumber TEXT NOT NULL,
statusCode TEXT,
statusDescription TEXT,
estimatedDelivery TEXT,
deliveredAt TEXT,
lastCheckedAt TEXT,
lastPostedStatus TEXT,
detectedAt TEXT NOT NULL,
sourceNote TEXT,
opsDeliveredNotifiedAt TEXT,
UNIQUE(workOrderId, trackingNumber)
)
`);
db.run(`
CREATE INDEX IF NOT EXISTS idx_shipment_tracking_active
ON shipment_tracking(deliveredAt)
`);
db.run(
`ALTER TABLE shipment_tracking ADD COLUMN opsDeliveredNotifiedAt TEXT`,
(err) => {
if (err && !/duplicate column name/i.test(err.message)) {
console.warn(`[DB] shipment_tracking migration: ${err.message}`);
}
}
);
// 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}`);
});
// ────────────────────────────────────────────────────────────────────────────────
// Core Webhook Processor (extracted logic — see src/services/webhookProcessor.js)
// We pass the existing db instance so we never create a second connection or touch
// the production DB file location.
// ────────────────────────────────────────────────────────────────────────────────
const hardcodedTeamId = 'Y2lzY29zcGFyazovL3VzL1RFQU0vMmI3MTJhZjAtZjc5NS0xMWYwLTk4MDYtYjczNjhlY2UzNjQx';
const defaultMembers = [
"mcqueenj@ae.com",
"bollandd@ae.com",
"ferrerij@ae.com",
"wagurakj@ae.com",
"karpuszkav@ae.com"
];
// Wrapper for the initial description summarizer used on WorkOrderCreated events.
// Prefer token passed in or from secrets (xai); fall back to legacy config only for transition.
async function summarizeForNewWO(rawDescription, token, opts = {}) {
const xaiToken = token || secrets.xai?.token || baseWebex.xai?.token;
return summarizeTicketDescription(rawDescription, xaiToken, opts);
}
// Webex service provides a thin, ServChan-aware layer over the raw bot client.
// This improves isolation and testability of the webhook processor.
const webexService = new WebexService();
const webhookProcessor = createWebhookProcessor({
db,
webex: webexService,
summarizeDescription: summarizeForNewWO,
teamId: hardcodedTeamId,
defaultMembers,
});
// ────────────────────────────────────────────────────────────────────────────────
// Webex Bot (Framework + commands)
// ────────────────────────────────────────────────────────────────────────────────
const Framework = initializeBot({
webexConfig,
});
// ────────────────────────────────────────────────────────────────────────────────
// Cron + Express App (extracted)
// ────────────────────────────────────────────────────────────────────────────────
setupCron({ db, runSpaceCleanup, webex: webexService });
logTrackingBootStatus();
const app = createApp({
db,
DB_PATH,
webhookProcessor,
runSpaceCleanup,
Framework,
getStaleWorkOrdersReport: (db) => getStaleWorkOrdersReport(db),
});
// ────────────────────────────────────────────────────────────────────────────────
// Start server (thin bootstrap)
// ────────────────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`${nonSecretConfig.server?.name || 'ServChan'} → Webex webhook receiver running on port ${PORT}`);
});
// Graceful shutdown (handles both local Ctrl-C and Docker/K8s SIGTERM)
function shutdown(signal) {
console.log(`[shutdown] Received ${signal}, stopping...`);
stopBot(Framework).then(() => {
try { db.close(); } catch (_) {}
process.exit(0);
}).catch(() => process.exit(0));
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));