import express from "express"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { pool } from "./db/pool.ts"; import cors from "cors"; import { authRequired } from "./lib/auth.ts"; import { authRouter } from "./routes/auth.ts"; import { businessRouter } from "./routes/business.ts"; import { clientsRouter } from "./routes/clients.ts"; import { appointmentsRouter } from "./routes/appointments.ts"; import { attendanceRouter } from "./routes/attendance.ts"; import { dayCloseRouter } from "./routes/dayClose.ts"; import { adminRouter } from "./routes/admin.ts"; import { crmRouter } from "./routes/crm.ts"; import { messagesRouter } from "./routes/messages.ts"; import { arrancarWorker } from "./crm/worker.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export function createApp() { const app = express(); app.use(cors()); app.use(express.json({ limit: "1mb" })); /** * Salud del servicio. * * Consulta la base a propósito. Un health check que solo responde `{ok:true}` * sigue en verde con Postgres caído o el disco lleno — justo el escenario en * que el orquestador debería reiniciar y no lo haría. */ app.get("/api/health", async (_req, res) => { try { await pool.query("SELECT 1"); res.json({ ok: true, db: "ok", ts: Date.now() }); } catch (e: any) { res.status(503).json({ ok: false, db: "error", error: String(e?.message ?? e) }); } }); app.use("/api/auth", authRouter); app.use("/api/business", authRequired, businessRouter); app.use("/api/clients", authRequired, clientsRouter); // Va ANTES que el router de citas: si se monta después, el `/:id` de // appointments se traga la ruta y `/attendance` nunca llega aquí. app.use("/api/appointments/:id/attendance", authRequired, attendanceRouter); app.use("/api/appointments", authRequired, appointmentsRouter); app.use("/api/day-close", authRequired, dayCloseRouter); app.use("/api/admin", authRequired, adminRouter); app.use("/api/crm", authRequired, crmRouter); app.use("/api/messages", authRequired, messagesRouter); // En producción este proceso también sirve el frontend compilado. El fallback // de SPA excluye `/api/` para que una ruta de API inexistente devuelva 404 y // no el index.html, que el cliente no sabría interpretar. const dist = path.resolve(__dirname, "..", "dist"); if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get(/^(?!\/api\/).*/, (_req, res) => res.sendFile(path.join(dist, "index.html"))); } // Traductor final de errores: sin esto, un rechazo dentro de un handler async // devuelve el HTML de stack de Express y el cliente no puede leer el mensaje. app.use( ( e: any, _req: express.Request, res: express.Response, _next: express.NextFunction ) => { console.error("[platform]", e); res.status(e?.status ?? 500).json({ error: e?.error ?? "Error interno del servidor" }); } ); return app; } const invoked = process.argv[1]?.replace(/\\/g, "/") ?? ""; if (invoked.endsWith("platform/index.ts")) { const port = Number(process.env.PLATFORM_PORT) || 3100; // 0.0.0.0 explícito: dentro de un contenedor, escuchar solo en localhost deja // el servicio inalcanzable desde la red del orquestador. const host = process.env.HOST || "0.0.0.0"; const server = createApp().listen(port, host, () => console.log(`[platform] escuchando en ${host}:${port}`) ); // Apagado ordenado: sin esto, cada redespliegue corta las peticiones en vuelo. for (const senal of ["SIGTERM", "SIGINT"] as const) { process.on(senal, () => { console.log(`[platform] ${senal} recibida, cerrando…`); server.close(() => { pool.end().finally(() => process.exit(0)); }); // Si algo se atasca, no se cuelga para siempre. setTimeout(() => process.exit(1), 10_000).unref(); }); } // Despacha la bandeja hacia el CRM. No se arranca en `createApp()` para que // las pruebas no salgan a la red por su cuenta. arrancarWorker(60_000); }