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>
121 lines
4.3 KiB
TypeScript
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);
|
|
});
|