import "dotenv/config"; import express from "express"; import { createServer } from "http"; import net from "net"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; import * as cron from "node-cron"; import { registerOAuthRoutes } from "./oauth"; import { appRouter } from "../routers"; import { createContext } from "./context"; import { serveStatic, setupVite } from "./vite"; import { runFullImport } from "../importer"; import uploadRoutes from "../uploadRoutes"; import scheduledRoutes from "../scheduledRoutes"; import { ensureAdminExists, generateLocalToken } from "../localAuth"; import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth"; import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db"; import { runRssFetch } from "../rssEngine"; function isPortAvailable(port: number): Promise { return new Promise(resolve => { const server = net.createServer(); server.listen(port, () => { server.close(() => resolve(true)); }); server.on("error", () => resolve(false)); }); } async function findAvailablePort(startPort: number = 3000): Promise { for (let port = startPort; port < startPort + 20; port++) { if (await isPortAvailable(port)) return port; } throw new Error(`No available port found starting from ${startPort}`); } // ─── Tâche d'import quotidien + lecture RSS ────────────────────────────────── let cronJob: ReturnType | null = null; export async function scheduleDailyImport() { const importTime = (await getSetting("import_time")) || "06:00"; const fetchMode = (await getSetting("fetch_mode")) || "scheduled"; const fetchIntervalMinutes = parseInt((await getSetting("fetch_interval_minutes")) || "1440", 10); const fetchDayOfWeek = parseInt((await getSetting("fetch_day_of_week")) || "1", 10); // 0=dim, 1=lun, ..., 6=sam const fetchDayOfMonth = parseInt((await getSetting("fetch_day_of_month")) || "1", 10); // 1-28 const DAYS_FR = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"]; let cronExpr: string; if (fetchMode === "interval") { const intervalMin = Math.max(60, fetchIntervalMinutes); if (intervalMin % 60 === 0) { const hours = intervalMin / 60; cronExpr = hours === 24 ? `0 0 * * *` : `0 */${hours} * * *`; } else { cronExpr = `*/${intervalMin} * * * *`; } console.log(`[Cron] Mode intervalle — toutes les ${intervalMin} minutes (${cronExpr})`); } else if (fetchMode === "weekly") { const [hour, minute] = importTime.split(":").map(Number); cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * ${fetchDayOfWeek}`; console.log(`[Cron] Mode hebdomadaire — chaque ${DAYS_FR[fetchDayOfWeek] ?? "lundi"} à ${importTime} (${cronExpr})`); } else if (fetchMode === "monthly") { const [hour, minute] = importTime.split(":").map(Number); cronExpr = `0 ${minute ?? 0} ${hour ?? 6} ${fetchDayOfMonth} * *`; console.log(`[Cron] Mode mensuel — le ${fetchDayOfMonth} de chaque mois à ${importTime} (${cronExpr})`); } else { // scheduled (heure fixe quotidienne) ou daily const [hour, minute] = importTime.split(":").map(Number); cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`; console.log(`[Cron] Mode quotidien — tous les jours à ${importTime} (${cronExpr})`); } if (cronJob) { cronJob.stop(); cronJob = null; } cronJob = cron.schedule(cronExpr, async () => { console.log(`[Cron] Import automatique démarré à ${new Date().toISOString()}`); try { // 1. Import des fichiers Excel (Veille + AAP) const result = await runFullImport(); console.log(`[Cron] Import Excel terminé — Veille: +${result.veille.newRows} | AAP: +${result.aap.newRows}`); } catch (e) { console.error("[Cron] Erreur lors de l'import Excel:", e); } try { // 2. Lecture des flux RSS const rssSummary = await runRssFetch(); console.log( `[Cron] Lecture RSS terminée — ${rssSummary.totalFeeds} flux, ` + `+${rssSummary.totalNewItems} nouveaux articles, ` + `${rssSummary.errorFeeds} erreur(s)` ); } catch (e) { console.error("[Cron] Erreur lors de la lecture RSS:", e); } // 3. Purge des articles selon la règle de rétention try { const retentionStr = await getSetting("retention_months"); const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0; if (retentionMonths > 0) { const purged = await purgeOldArticles(retentionMonths); if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) { console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`); } } } catch (e) { console.error("[Cron] Erreur lors de la purge de rétention:", e); } }); } /** * Stub conservé pour compatibilité avec les imports existants dans routers.ts. * La lecture RSS est désormais intégrée dans scheduleDailyImport(). */ export async function scheduleRssFetch() { // No-op : la lecture RSS est pilotée par le cron d'import quotidien (import_time) console.log("[RSS Cron] Planificateur RSS indépendant désactivé — la lecture est pilotée par le cron d'import quotidien."); } async function startServer() { const app = express(); const server = createServer(app); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); registerOAuthRoutes(app); app.use(uploadRoutes); app.use(scheduledRoutes); // ─── Azure AD OAuth2 callback ───────────────────────────────────────────── app.get("/api/auth/azure/callback", async (req, res) => { const code = req.query.code as string | undefined; const error = req.query.error as string | undefined; if (error) { res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refus\u00e9e")}`); return; } if (!code) { res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant")); return; } if (!isAzureAdConfigured()) { res.redirect("/login?error=" + encodeURIComponent("Azure AD non configur\u00e9")); return; } try { const azureUser = await handleAzureCallback(code); // Chercher par azureAdId puis par email let user = await getLocalUserByAzureAdId(azureUser.azureAdId); if (!user) user = await getLocalUserByEmail(azureUser.email); if (!user) { // Cr\u00e9er automatiquement avec r\u00f4le "user" await upsertLocalUserAzure({ email: azureUser.email, name: azureUser.name, azureAdId: azureUser.azureAdId, role: "user", }); user = await getLocalUserByEmail(azureUser.email); } else if (!user.azureAdId) { // Lier le compte existant \u00e0 Azure AD await upsertLocalUserAzure({ email: user.email ?? azureUser.email, azureAdId: azureUser.azureAdId, }); } if (!user || !user.isActive) { res.redirect("/login?error=" + encodeURIComponent("Compte inactif ou introuvable")); return; } // Mettre \u00e0 jour lastSignedIn const db = await (await import("../db")).getDb(); if (db) { const { localUsers } = await import("../../drizzle/schema"); const { eq } = await import("drizzle-orm"); await db.update(localUsers).set({ lastSignedIn: new Date() }).where(eq(localUsers.id, user.id)); } // G\u00e9n\u00e9rer le token JWT local et le stocker dans le cookie const token = await generateLocalToken(user.id, user.role); res.cookie("veille_local_auth", token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 7 * 24 * 60 * 60 * 1000, }); // Retourner les infos user en JSON pour que le frontend hydrate LocalAuthContext const userPayload = JSON.stringify({ id: user.id, name: user.name, username: user.username ?? null, email: user.email ?? null, role: user.role, }); // Rediriger vers une page de callback qui hydrate le contexte res.redirect(`/azure-callback?user=${encodeURIComponent(userPayload)}`); } catch (err: any) { console.error("[Azure AD] Erreur callback:", err.message); res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft")); } }); app.use( "/api/trpc", createExpressMiddleware({ router: appRouter, createContext }) ); if (process.env.NODE_ENV === "development") { await setupVite(app, server); } else { serveStatic(app); } const preferredPort = parseInt(process.env.PORT || "3000"); const port = await findAvailablePort(preferredPort); if (port !== preferredPort) { console.log(`Port ${preferredPort} is busy, using port ${port} instead`); } server.listen(port, async () => { console.log(`Server running on http://localhost:${port}/`); // Initialisation post-démarrage try { await ensureAdminExists(); await scheduleDailyImport(); // Purge de rétention au démarrage const retentionStr = await getSetting("retention_months"); const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0; if (retentionMonths > 0) { const purged = await purgeOldArticles(retentionMonths); if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) { console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`); } } } catch (e) { console.error("[Init] Erreur d'initialisation:", e); } }); } // Les routeurs importent certaines fonctions de ce module. Vitest ne doit jamais // démarrer un serveur HTTP à cet effet, sinon les suites parallèles se disputent un port. if (!process.env.VITEST) { startServer().catch(console.error); }