Phase 4 + 6
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// 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');
|
||||
});
|
||||
Reference in New Issue
Block a user