// dashboard-server — Phase 4 of CONSOLIDATION_PLAN.md. // Merges the four per-vendor PM2 apps (klaviyo, meta, google-analytics, typeform) // into a single ESM service on DASHBOARD_PORT (default 3015). // // Mount points (matches Caddy proxy paths): // /api/klaviyo/* → routes/klaviyo (was klaviyo-server :3004) // /api/meta/* → routes/meta (was meta-server :3005) // /api/dashboard-analytics/* → routes/google (was google-server :3007 via Caddy /api/analytics rewrite) // /api/typeform/* → routes/typeform (was typeform-server :3008) // // Shared infrastructure (Phase 2 + Phase 6): // - shared/auth/middleware.js authenticate() guards /api/* (Phase 6.1/6.2 — second line of defense) // - shared/cors/policy.js explicit allowed-origins list (Phase 6.6) // - shared/logging/request-log.js pino-http, Authorization/Cookie redacted (Phase 6.5/6.9) // - shared/errors/handler.js consistent error envelope, no leak in prod // - shared/db/pg.js / shared/db/redis.js one Pool + one ioredis client for all vendors // // Per-route permission gates (Phase 6.2): // - meta_write PATCH/POST mutations to Meta campaigns // - klaviyo_admin POST /api/klaviyo/events/clearCache (operational maintenance) // Read-only Google + Typeform endpoints stay authenticated-only. import { config as loadEnv } from 'dotenv'; import express from 'express'; import cors from 'cors'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { authenticate } from '../shared/auth/middleware.js'; import { corsOptions } from '../shared/cors/policy.js'; import { createPool } from '../shared/db/pg.js'; import { createRedis } from '../shared/db/redis.js'; import { errorHandler } from '../shared/errors/handler.js'; import { logger } from '../shared/logging/logger.js'; import { requestLog } from '../shared/logging/request-log.js'; import { createKlaviyoRouter } from './routes/klaviyo/index.js'; import { createMetaRouter } from './routes/meta/index.js'; import { createGoogleRouter } from './routes/google/index.js'; import { createTypeformRouter } from './routes/typeform/index.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Layer envs: shared inventory .env wins on collisions (security-critical vars come // from one place); vendor-specific keys come from the per-service .env. // // dotenv defaults to override:false so the first file loaded wins. Order matters. const sharedEnvPath = '/var/www/inventory/.env'; const dashboardEnvPath = path.resolve(__dirname, '.env'); if (fs.existsSync(sharedEnvPath)) loadEnv({ path: sharedEnvPath }); if (fs.existsSync(dashboardEnvPath)) loadEnv({ path: dashboardEnvPath }); // Phase 6.4 — refuse to start without JWT_SECRET. Without it authenticate() falls // back to res.status(401) on every request and the service is useless anyway. if (!process.env.JWT_SECRET) { logger.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)'); process.exit(1); } const app = express(); const PORT = Number(process.env.DASHBOARD_PORT) || 3015; // Single Postgres pool — used by authenticate() to load user permissions. // All four vendors share this pool (auth lookups are the only DB hits at runtime). const pool = createPool('DB'); // Single ioredis client shared across all vendors. lazyConnect:true means the // first .get/.set triggers the actual connect — keeps startup non-blocking even // if Redis is temporarily unavailable, and aligns with shared/db/redis.js defaults. const redis = createRedis(); app.use(requestLog()); app.use(cors(corsOptions)); app.use(express.json({ limit: '10mb' })); // Phase 6.1/6.2: every /api request requires a valid JWT. authenticate() also // loads user permissions, which the per-route requirePermission() checks rely on. app.use('/api', authenticate({ pool, secret: process.env.JWT_SECRET })); app.use('/api/klaviyo', createKlaviyoRouter({ redis })); app.use('/api/meta', createMetaRouter()); // Note: frontend calls /api/dashboard-analytics (Caddy used to rewrite it to // /api/analytics for the standalone google-server). Mount at the public path so // Caddy can drop the rewrite — see Caddyfile.proposed. app.use('/api/dashboard-analytics', createGoogleRouter({ redis })); app.use('/api/typeform', createTypeformRouter({ redis })); app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), service: 'dashboard-server', redis: redis.status, }); }); app.use(errorHandler); // Connect Redis up front so the first request doesn't pay the connect cost. // Failures here are non-fatal — vendors degrade to cache-miss → upstream fetch. redis.connect().catch((err) => { logger.error({ err: { message: err.message, code: err.code } }, 'redis lazy-connect failed'); }); const server = app.listen(PORT, '0.0.0.0', () => { logger.info({ port: PORT, mode: process.env.NODE_ENV || 'development' }, 'dashboard-server listening'); }); const shutdown = async (signal) => { logger.info({ signal }, 'dashboard-server shutting down'); server.close(); try { await redis.quit(); } catch { /* ignore */ } try { await pool.end(); } catch { /* ignore */ } process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('uncaughtException', (err) => { logger.error({ err: { message: err.message, stack: err.stack } }, 'uncaughtException'); process.exit(1); }); process.on('unhandledRejection', (reason) => { logger.error({ reason }, 'unhandledRejection'); });