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}`); } } ); 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'));