import express from 'express' import fs from 'node:fs' import type http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { apiRouter } from './api' import { maybePullAwsCosts } from './aws-costs' import { pollBounces } from './bounces' import { openDb, type DB } from './db' import { renderPdf } from './pdf' import { runDailyScan } from './scheduler' import { seedIfEmpty } from './seed' // dist/server.cjs (esbuild CJS bundle) has __dirname; vitest/tsx run this file as ESM. // Both src/ and dist/ sit one level under apps/hq, so ../../hq-web/dist works from either. const moduleDir = typeof __dirname === 'undefined' ? path.dirname(fileURLToPath(import.meta.url)) : __dirname function schedulerDeps(db: DB) { const company = (): Record => Object.fromEntries((db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`) .all() as { key: string; value: string }[]).map((r) => [r.key, r.value])) const gmail = { f: fetch, clientId: process.env['GOOGLE_CLIENT_ID'] ?? '', clientSecret: process.env['GOOGLE_CLIENT_SECRET'] ?? '', keyHex: process.env['HQ_SECRET_KEY'] ?? '', } return { gmail, renderPdf, company } } /** Boot the scan + bounce poll now and every 6h. unref() so it never blocks exit. */ export function startScheduler(db: DB): NodeJS.Timeout { const deps = schedulerDeps(db) const awsCreds = { accessKeyId: process.env['AWS_ACCESS_KEY_ID'] ?? '', secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'] ?? '', } const today = (): string => new Date().toISOString().slice(0, 10) const tick = (): void => { void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e)) void pollBounces(db, deps).catch((e: unknown) => console.error('[scheduler] bounce poll failed', e)) // AWS cost pull only when credentials are configured — env-gated, once per month. if (awsCreds.accessKeyId !== '' && awsCreds.secretAccessKey !== '') { void maybePullAwsCosts(db, { f: fetch, creds: awsCreds }, today()) .catch((e: unknown) => console.error('[scheduler] aws cost pull failed', e)) } } tick() const handle = setInterval(tick, 6 * 60 * 60 * 1000) handle.unref() return handle } export function startServer(port: number, opts: { scheduler?: boolean } = {}): http.Server { const app = express() const db = openDb(process.env['HQ_DATA_DIR']) seedIfEmpty(db) // first boot prints the owner password once app.locals['db'] = db app.use(express.json({ limit: '2mb' })) app.use('/api', apiRouter(db)) const webDist = path.resolve(moduleDir, '../../hq-web/dist') if (fs.existsSync(webDist)) app.use('/', express.static(webDist)) if (opts.scheduler === true) startScheduler(db) return app.listen(port) } // Direct launch (bundled dist/server.cjs); vitest imports startServer instead. if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) { const PORT = Number(process.env['HQ_PORT'] ?? 5182) startServer(PORT, { scheduler: true }) console.log(`SiMS HQ console on http://localhost:${PORT}`) }