servchan/index.js
jmcqueen 7ea64b5f24 Refine space cleanup rules and add daily close-out reminders.
Auto-remove only terminal COMPLETED statuses (Confirmed, Cancelled, No Charge), post daily reminders for other COMPLETED variants until they reach a terminal state, and schedule reminder cron with dedup tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 16:05:39 -04:00

167 lines
No EOL
8.2 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';
// ────────────────────────────────────────────────────────────────────────────────
// 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 space_cleanup_reminders (
workOrderId INTEGER PRIMARY KEY,
lastReminderAt TEXT NOT NULL
)
`);
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 });
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'));