- Add AdminSettings page with 4 tabs: Integrations, WhatsApp, Notifications, General - Add tRPC admin endpoints: listConfig, setConfig, deleteConfig, testStripeConnection, testSmsConnection - Add clinicSettings.toggleSms endpoint for per-clinic SMS toggle - Add app_config table schema + DB helpers (listAllConfig, setConfigValue, deleteConfigValue) - Stripe and SMS services now read config from DB first, then env vars fallback - Add Settings nav item in sidebar (admin only) - Add /admin/settings route in App.tsx
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import express from "express";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import fs from "node:fs";
|
|
import cors from "cors";
|
|
import helmet from "helmet";
|
|
import rateLimit from "express-rate-limit";
|
|
import cookieParser from "cookie-parser";
|
|
import { Server as SocketIOServer } from "socket.io";
|
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
|
import { appRouter } from "../routers.js";
|
|
import { createContext } from "./context.js";
|
|
import { authMiddleware, assertAuthEnv } from "../auth.js";
|
|
import { getDb, pingDb } from "../db.js";
|
|
import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js";
|
|
import {
|
|
handleWebhook as handleStripeWebhook,
|
|
isStripeConfigured,
|
|
verifyAndConstructEvent,
|
|
} from "../services/stripe.js";
|
|
import { getActiveWhatsAppSessionsCount } from "../services/whatsapp.js";
|
|
import { logger, childLogger } from "./logger.js";
|
|
import { requestLogger } from "./requestLogger.js";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, "..", "..");
|
|
const PORT = Number(process.env.PORT ?? 5000);
|
|
const NODE_ENV = process.env.NODE_ENV ?? "development";
|
|
const IS_PROD = NODE_ENV === "production";
|
|
const BACKUP_DIR = process.env.BACKUP_DIR ?? "/app/data/backups";
|
|
|
|
const PROD_ORIGINS = ["https://attente.cosmolan.fr"];
|
|
const DEV_ORIGINS = [
|
|
"http://localhost:5173",
|
|
"http://127.0.0.1:5173",
|
|
"http://localhost:5000",
|
|
"http://127.0.0.1:5000",
|
|
];
|
|
const ALLOWED_ORIGINS = IS_PROD ? PROD_ORIGINS : DEV_ORIGINS;
|
|
|
|
const serverLog = childLogger("server");
|
|
const dbLog = childLogger("db");
|
|
const stripeLog = childLogger("stripe");
|
|
const trpcLog = childLogger("trpc");
|
|
|
|
function readPackageVersion(): string {
|
|
try {
|
|
const pkgPath = path.resolve(ROOT, "package.json");
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { version?: string };
|
|
return pkg.version ?? "0.0.0";
|
|
} catch {
|
|
return "0.0.0";
|
|
}
|
|
}
|
|
const APP_VERSION = readPackageVersion();
|
|
|
|
function getLastBackupTimestamp(): string | null {
|
|
try {
|
|
if (!fs.existsSync(BACKUP_DIR)) return null;
|
|
const files = fs.readdirSync(BACKUP_DIR);
|
|
let mostRecent = 0;
|
|
for (const file of files) {
|
|
const stat = fs.statSync(path.join(BACKUP_DIR, file));
|
|
if (stat.isFile() && stat.mtimeMs > mostRecent) {
|
|
mostRecent = stat.mtimeMs;
|
|
}
|
|
}
|
|
return mostRecent > 0 ? new Date(mostRecent).toISOString() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function bootstrap() {
|
|
// Fail fast if critical secrets are missing — refuse to start instead of
|
|
// erroring lazily on the first authenticated request.
|
|
assertAuthEnv();
|
|
|
|
// Eagerly initialize database connection (warns early if DATABASE_URL missing)
|
|
try {
|
|
await getDb();
|
|
dbLog.info("connected");
|
|
} catch (err) {
|
|
dbLog.error({ err }, "connection failed");
|
|
}
|
|
|
|
const app = express();
|
|
// Required for express-rate-limit and secure cookies behind a reverse proxy
|
|
app.set("trust proxy", 1);
|
|
const httpServer = http.createServer(app);
|
|
|
|
// ── Socket.io ────────────────────────────────────────────────────────────
|
|
const io = new SocketIOServer(httpServer, {
|
|
cors: {
|
|
origin: ALLOWED_ORIGINS,
|
|
credentials: true,
|
|
},
|
|
path: "/socket.io",
|
|
});
|
|
(globalThis as unknown as { __socketIo: SocketIOServer }).__socketIo = io;
|
|
|
|
io.on("connection", (socket) => {
|
|
socket.on("clinic:subscribe", (clinicId: number) => {
|
|
if (typeof clinicId === "number") socket.join(`clinic:${clinicId}`);
|
|
});
|
|
socket.on("display:subscribe", (clinicId: number) => {
|
|
if (typeof clinicId === "number") socket.join(`display:${clinicId}`);
|
|
});
|
|
socket.on("patient:subscribe", (patientToken: string) => {
|
|
if (typeof patientToken === "string" && patientToken.length > 0) {
|
|
socket.join(`patient:${patientToken}`);
|
|
}
|
|
});
|
|
socket.on("clinic:unsubscribe", (clinicId: number) => {
|
|
if (typeof clinicId === "number") socket.leave(`clinic:${clinicId}`);
|
|
});
|
|
socket.on("display:unsubscribe", (clinicId: number) => {
|
|
if (typeof clinicId === "number") socket.leave(`display:${clinicId}`);
|
|
});
|
|
socket.on("patient:unsubscribe", (patientToken: string) => {
|
|
if (typeof patientToken === "string") socket.leave(`patient:${patientToken}`);
|
|
});
|
|
});
|
|
|
|
// ── Request logging (must come early to capture every API request) ───────
|
|
app.use(requestLogger);
|
|
|
|
// ── Security headers ─────────────────────────────────────────────────────
|
|
// CSP is intentionally disabled here: it conflicts with the Vite dev server
|
|
// and the inline assets generated by the SPA build. Re-enable once a proper
|
|
// policy has been defined for the production bundle.
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: false,
|
|
crossOriginEmbedderPolicy: false,
|
|
})
|
|
);
|
|
|
|
// ── CORS ─────────────────────────────────────────────────────────────────
|
|
app.use(
|
|
cors({
|
|
origin: (origin, callback) => {
|
|
// Allow same-origin / non-browser requests (no Origin header)
|
|
if (!origin) return callback(null, true);
|
|
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
|
|
return callback(new Error(`Origin not allowed by CORS: ${origin}`));
|
|
},
|
|
credentials: true,
|
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
|
})
|
|
);
|
|
|
|
// ── Rate limiting ────────────────────────────────────────────────────────
|
|
// Global limiter applied to the whole API surface.
|
|
const globalLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 100,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: "Too many requests, please try again later." },
|
|
});
|
|
|
|
// Stricter limiter for authentication endpoints (login + register).
|
|
const authLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 5,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
skipSuccessfulRequests: true,
|
|
message: { error: "Too many auth attempts, please try again later." },
|
|
});
|
|
|
|
// Password reset limiter — kept ready in case a reset endpoint is added.
|
|
const passwordResetLimiter = rateLimit({
|
|
windowMs: 60 * 60 * 1000,
|
|
max: 3,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: "Too many password reset attempts, please try again later." },
|
|
});
|
|
|
|
// Apply auth limiter BEFORE the tRPC middleware so it short-circuits abuse.
|
|
app.use(
|
|
[
|
|
"/api/trpc/auth.login",
|
|
"/api/trpc/auth.register",
|
|
],
|
|
authLimiter
|
|
);
|
|
app.use(
|
|
[
|
|
"/api/trpc/auth.requestPasswordReset",
|
|
"/api/trpc/auth.resetPassword",
|
|
"/api/trpc/auth.forgotPassword",
|
|
],
|
|
passwordResetLimiter
|
|
);
|
|
app.use("/api", globalLimiter);
|
|
|
|
// ── Stripe webhook (RAW body, must come BEFORE express.json) ─────────────
|
|
app.post(
|
|
"/api/stripe/webhook",
|
|
express.raw({ type: "application/json", limit: "1mb" }),
|
|
async (req, res) => {
|
|
// If Stripe is not configured, silently acknowledge so the route never 500s.
|
|
if (!(await isStripeConfigured())) {
|
|
res.status(200).json({ received: true, configured: false });
|
|
return;
|
|
}
|
|
const signature = req.headers["stripe-signature"];
|
|
if (typeof signature !== "string") {
|
|
res.status(400).json({ error: "Missing stripe-signature header" });
|
|
return;
|
|
}
|
|
try {
|
|
const event = await verifyAndConstructEvent(req.body as Buffer, signature);
|
|
await handleStripeWebhook(event);
|
|
res.status(200).json({ received: true });
|
|
} catch (err) {
|
|
stripeLog.error({ err }, "webhook error");
|
|
const message = err instanceof Error ? err.message : "Webhook error";
|
|
res.status(400).json({ error: message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// ── Body / cookies / auth ────────────────────────────────────────────────
|
|
app.use(express.json({ limit: "1mb" }));
|
|
app.use(cookieParser());
|
|
app.use(authMiddleware);
|
|
|
|
// ── Health / readiness / liveness probes ─────────────────────────────────
|
|
app.get("/api/health", async (_req, res) => {
|
|
const dbStatus = await pingDb();
|
|
const lastBackup = getLastBackupTimestamp();
|
|
res.json({
|
|
status: dbStatus.ok ? "ok" : "degraded",
|
|
env: NODE_ENV,
|
|
version: APP_VERSION,
|
|
uptime: process.uptime(),
|
|
ts: Date.now(),
|
|
database: dbStatus.ok
|
|
? { status: "connected", latencyMs: dbStatus.latencyMs }
|
|
: { status: "error", error: dbStatus.error },
|
|
whatsapp: {
|
|
activeSessions: getActiveWhatsAppSessionsCount(),
|
|
},
|
|
lastBackup,
|
|
});
|
|
});
|
|
|
|
// k8s-style probes
|
|
app.get("/api/live", (_req, res) => {
|
|
res.json({ status: "ok", ts: Date.now() });
|
|
});
|
|
|
|
app.get("/api/ready", async (_req, res) => {
|
|
const dbStatus = await pingDb();
|
|
if (!dbStatus.ok) {
|
|
res.status(503).json({ status: "not_ready", database: "error", error: dbStatus.error });
|
|
return;
|
|
}
|
|
res.json({ status: "ready", database: "connected", latencyMs: dbStatus.latencyMs });
|
|
});
|
|
|
|
// ── tRPC ─────────────────────────────────────────────────────────────────
|
|
app.use(
|
|
"/api/trpc",
|
|
createExpressMiddleware({
|
|
router: appRouter,
|
|
createContext,
|
|
onError({ error, path }) {
|
|
if (error.code === "INTERNAL_SERVER_ERROR") {
|
|
trpcLog.error({ err: error, path }, "internal server error");
|
|
} else {
|
|
trpcLog.debug({ code: error.code, path, message: error.message }, "trpc error");
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
// ── Static client (production) ───────────────────────────────────────────
|
|
if (IS_PROD) {
|
|
const clientDist = path.resolve(ROOT, "dist", "client");
|
|
const indexHtml = path.resolve(clientDist, "index.html");
|
|
|
|
if (fs.existsSync(clientDist)) {
|
|
app.use(express.static(clientDist, { maxAge: "1h", index: false }));
|
|
app.get("*", (req, res, next) => {
|
|
if (req.path.startsWith("/api") || req.path.startsWith("/socket.io")) return next();
|
|
if (!fs.existsSync(indexHtml)) return next();
|
|
res.sendFile(indexHtml);
|
|
});
|
|
} else {
|
|
serverLog.warn({ clientDist }, "static dist/client not found");
|
|
}
|
|
}
|
|
|
|
// ── Error handler ────────────────────────────────────────────────────────
|
|
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
serverLog.error({ err }, "express error");
|
|
res.status(500).json({ error: "Internal Server Error" });
|
|
});
|
|
|
|
httpServer.listen(PORT, () => {
|
|
serverLog.info({ port: PORT, env: NODE_ENV, version: APP_VERSION }, "server listening");
|
|
// Démarre le job qui marque les patients absents après N minutes sans réponse
|
|
startAutoAbsentJob();
|
|
});
|
|
|
|
const shutdown = (signal: string) => {
|
|
serverLog.info({ signal }, "received shutdown signal");
|
|
stopAutoAbsentJob();
|
|
io.close();
|
|
httpServer.close(() => process.exit(0));
|
|
setTimeout(() => process.exit(1), 10_000).unref();
|
|
};
|
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
}
|
|
|
|
bootstrap().catch((err) => {
|
|
logger.fatal({ err }, "server failed to start");
|
|
process.exit(1);
|
|
});
|