Files
pn-new-crm/src/server.ts
Matt Ciaccio c5b41ca4b5 fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.

R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.

R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00

121 lines
4.3 KiB
TypeScript

/**
* Custom server entry point.
*
* Boots Next.js, attaches Socket.io to the same HTTP server, and in
* development mode also registers BullMQ recurring jobs and starts the
* workers inline (so you don't need the separate crm-worker container).
*
* In production the workers run in the crm-worker container (Dockerfile.worker
* → dist/worker.js) and this file only handles Next.js + Socket.io.
*/
import { createServer, type Server as HttpServer } from 'node:http';
import next from 'next';
import { initSocketServer, closeSocketServer } from '@/lib/socket/server';
import { logger } from '@/lib/logger';
import { env } from '@/lib/env';
import { redis } from '@/lib/redis';
const dev = env.NODE_ENV !== 'production';
async function gracefulShutdown(signal: string, httpServer: HttpServer): Promise<void> {
logger.info({ signal }, 'Shutdown signal received; closing connections');
// Order matters: close Socket.io first so it stops accepting new
// sockets and emits disconnect events while the HTTP server is still
// up to flush them. `httpServer.close` only stops new connections;
// it waits for keep-alive HTTP and long-poll websockets to drain on
// their own, so without an explicit io.close() upfront the polls hold
// the server past the compose stop_grace_period and the process gets
// SIGKILL'd mid-frame.
await closeSocketServer().catch((err) => logger.warn({ err }, 'closeSocketServer error'));
// Then drain the HTTP layer.
await new Promise<void>((resolve) => {
httpServer.close((err) => {
if (err) logger.warn({ err }, 'httpServer.close emitted error');
resolve();
});
// Hard timeout — 25s leaves headroom under a 30s compose grace
// period before SIGKILL would arrive anyway.
setTimeout(() => resolve(), 25_000).unref();
});
try {
redis.disconnect();
} catch (err) {
logger.warn({ err }, 'redis.disconnect error');
}
logger.info({ signal }, 'Shutdown complete');
}
async function main(): Promise<void> {
const app = next({ dev, port: env.PORT });
const handle = app.getRequestHandler();
await app.prepare();
const httpServer = createServer((req, res) => {
handle(req, res);
});
// Attach Socket.io to the HTTP server (uses Redis adapter for pub/sub)
initSocketServer(httpServer);
logger.info('Socket.io initialized');
// In development, run BullMQ workers inline so a single `pnpm dev` is enough
if (dev) {
const { registerRecurringJobs } = await import('@/lib/queue/scheduler');
const { emailWorker } = await import('@/lib/queue/workers/email');
const { documentsWorker } = await import('@/lib/queue/workers/documents');
const { notificationsWorker } = await import('@/lib/queue/workers/notifications');
const { importWorker } = await import('@/lib/queue/workers/import');
const { exportWorker } = await import('@/lib/queue/workers/export');
const { aiWorker } = await import('@/lib/queue/workers/ai');
const { bulkWorker } = await import('@/lib/queue/workers/bulk');
const { maintenanceWorker } = await import('@/lib/queue/workers/maintenance');
const { reportsWorker } = await import('@/lib/queue/workers/reports');
const { webhooksWorker } = await import('@/lib/queue/workers/webhooks');
await registerRecurringJobs();
logger.info('BullMQ recurring jobs registered (dev mode)');
// Keep a reference so workers aren't GC'd
void [
emailWorker,
documentsWorker,
notificationsWorker,
importWorker,
exportWorker,
aiWorker,
bulkWorker,
maintenanceWorker,
reportsWorker,
webhooksWorker,
];
}
httpServer.listen(env.PORT, () => {
logger.info({ port: env.PORT, env: env.NODE_ENV }, 'Port Nimara CRM server listening');
});
// Graceful stop on container restart / deploy. Without this, every
// `docker compose up -d` rolling restart drops in-flight uploads, EOI
// generation, Documenso requests, and Socket.io frames mid-statement.
// Match docker-compose `stop_grace_period: 30s` (or longer) so the
// 25s drain inside gracefulShutdown can complete before SIGKILL.
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.once(sig, () => {
void gracefulShutdown(sig, httpServer).finally(() => process.exit(0));
});
}
}
main().catch((err) => {
logger.error(err, 'Failed to start server');
process.exit(1);
});