import fs from "node:fs" import path from "node:path" import process from "node:process" import { PassThrough } from "node:stream" import archiver from "archiver" import mysql from "mysql2/promise" import nodemailer from "nodemailer" const ROOT_DIR = process.cwd() const DEFAULT_OUTPUT_PATH = "exports/inscriptions-lycee-{date}.xlsx" const columns = [ ["id", "ID demande"], ["created_at", "Date demande"], ["nom_etablissement", "Nom établissement"], ["departement", "Département"], ["ville", "Ville"], ["coordinateur_nom", "Coordinateur"], ["email", "Email"], ["telephone", "Téléphone"], ["discipline", "Discipline"], ["niveau_classe", "Niveau classe"], ["nombre_eleves", "Nombre élèves"], ["priorite_concert_rencontre", "Priorité concert-rencontre"], ["priorite_immersion", "Priorité immersion"], ["priorite_eac", "Priorité EAC"], ["priorite_concert_commente", "Priorité concert commenté"], ["periode_souhaitee", "Période souhaitée"], ] function loadDotEnv(filePath = path.join(ROOT_DIR, ".env")) { if (!fs.existsSync(filePath)) { return } const content = fs.readFileSync(filePath, "utf8") for (const line of content.split(/\r?\n/)) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) { continue } const index = trimmed.indexOf("=") const key = trimmed.slice(0, index).trim() let value = trimmed.slice(index + 1).trim() if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } if (!process.env[key]) { process.env[key] = value } } } function requireEnv(name) { const value = process.env[name] if (!value) { throw new Error(`Variable d'environnement manquante : ${name}`) } return value } function optionalEnv(name, fallback = "") { return process.env[name] || fallback } function formatLocalDate(date = new Date()) { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, "0") const day = String(date.getDate()).padStart(2, "0") return `${year}-${month}-${day}` } function resolveDatedPath(filePath) { return filePath.replaceAll("{date}", formatLocalDate()) } function getSmtpConfig() { return { host: optionalEnv("NUXT_SMTP_HOST"), port: Number(optionalEnv("NUXT_SMTP_PORT", "587")), secure: optionalEnv("NUXT_SMTP_SECURE", "false").toLowerCase() === "true", user: optionalEnv("NUXT_SMTP_USER"), password: optionalEnv("NUXT_SMTP_PASSWORD"), fromEmail: optionalEnv("NUXT_SMTP_FROM_EMAIL"), fromName: optionalEnv("NUXT_SMTP_FROM_NAME"), reportEmail: optionalEnv("ACADEMIE_EXCEL_REPORT_EMAIL", optionalEnv("NUXT_SMTP_FROM_EMAIL")), } } function isSmtpConfigured(config) { return Boolean(config.host && config.port && config.user && config.password && config.fromEmail && config.reportEmail) } async function sendExportReport({ ok, mode, rowsCount = 0, localOutputPath = "", uploadPath = "", uploadedName = "", skippedUpload = false, error = null }) { const config = getSmtpConfig() if (!isSmtpConfigured(config)) { console.warn("Email de rapport non envoyé : configuration SMTP incomplète.") return false } const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 15000, auth: { user: config.user, pass: config.password, }, }) const from = config.fromName ? `"${config.fromName}" <${config.fromEmail}>` : config.fromEmail const subject = ok ? `Export Lycée OK - ${formatLocalDate()}` : `Export Lycée ERREUR - ${formatLocalDate()}` const text = ok ? [ "L'export Excel des demandes projet lycée s'est terminé correctement.", "", `Mode : ${mode}`, `Demandes exportées : ${rowsCount}`, localOutputPath ? `Fichier local : ${localOutputPath}` : "Fichier local : non généré", skippedUpload ? "Upload SharePoint : ignoré" : `Fichier SharePoint : ${uploadPath || uploadedName}`, uploadedName && uploadedName !== uploadPath ? `Nom retourné par SharePoint : ${uploadedName}` : null, ].filter(Boolean).join("\n") : [ "L'export Excel des demandes projet lycée a échoué.", "", `Mode : ${mode || "non déterminé"}`, `Erreur : ${error?.message || String(error)}`, ].join("\n") await transporter.sendMail({ from, to: config.reportEmail, subject, text, }) return true } async function trySendExportReport(report) { try { return await sendExportReport(report) } catch (error) { console.error("Erreur envoi email de rapport :", error) return false } } function escapeXml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'") } function columnName(index) { let name = "" let number = index + 1 while (number > 0) { const modulo = (number - 1) % 26 name = String.fromCharCode(65 + modulo) + name number = Math.floor((number - modulo) / 26) } return name } function formatCellValue(value) { if (value === null || value === undefined) { return "" } if (value instanceof Date) { return value.toISOString().replace("T", " ").slice(0, 19) } return String(value) } function buildSheetXml(rows) { const allRows = [columns.map(([, label]) => label), ...rows.map((row) => columns.map(([key]) => formatCellValue(row[key])))] const sheetRows = allRows .map((row, rowIndex) => { const rowNumber = rowIndex + 1 const cells = row .map((value, columnIndex) => { const reference = `${columnName(columnIndex)}${rowNumber}` return `${escapeXml(value)}` }) .join("") return `${cells}` }) .join("") const lastColumn = columnName(columns.length - 1) const lastRow = Math.max(allRows.length, 1) return ` ${columns.map((_, index) => ``).join("")} ${sheetRows} ` } async function createXlsxBuffer(rows) { const archive = archiver("zip", { zlib: { level: 9 } }) const stream = new PassThrough() const chunks = [] stream.on("data", (chunk) => chunks.push(chunk)) const completed = new Promise((resolve, reject) => { stream.on("end", () => resolve(Buffer.concat(chunks))) stream.on("error", reject) archive.on("error", reject) }) archive.pipe(stream) archive.append(` `, { name: "[Content_Types].xml" }) archive.append(` `, { name: "_rels/.rels" }) archive.append(` `, { name: "xl/workbook.xml" }) archive.append(` `, { name: "xl/_rels/workbook.xml.rels" }) archive.append(buildSheetXml(rows), { name: "xl/worksheets/sheet1.xml" }) archive.append(` `, { name: "xl/styles.xml" }) archive.append(` wondif_vue `, { name: "docProps/app.xml" }) archive.append(` Demandes projet lycée wondif_vue wondif_vue ${new Date().toISOString()} ${new Date().toISOString()} `, { name: "docProps/core.xml" }) await archive.finalize() return completed } async function getRows(db, mode) { const where = mode === "pending" ? "WHERE exported_to_excel = 0" : "" const selectColumns = columns.map(([key]) => key).join(", ") const [rows] = await db.execute(` SELECT ${selectColumns} FROM projet_lycee_demandes ${where} ORDER BY created_at ASC, id ASC `) return rows } async function getAccessToken() { const tenantId = requireEnv("MICROSOFT_TENANT_ID") const clientId = requireEnv("MICROSOFT_CLIENT_ID") const clientSecret = requireEnv("MICROSOFT_CLIENT_SECRET") const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, scope: "https://graph.microsoft.com/.default", grant_type: "client_credentials", }) const response = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }) if (!response.ok) { throw new Error(`Erreur token Microsoft Graph (${response.status}) : ${await response.text()}`) } const data = await response.json() return data.access_token } async function uploadToMicrosoft365(buffer) { const driveId = requireEnv("GRAPH_DRIVE_ID") const itemId = optionalEnv("LYCEE_GRAPH_EXCEL_ITEM_ID") const uploadPath = resolveDatedPath(optionalEnv("LYCEE_GRAPH_EXCEL_UPLOAD_PATH", DEFAULT_OUTPUT_PATH)).replace(/^\/+/, "") const token = await getAccessToken() const url = itemId ? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/content` : `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:/${uploadPath.split("/").map(encodeURIComponent).join("/")}:/content` const response = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }, body: buffer, }) if (!response.ok) { throw new Error(`Erreur upload Microsoft Graph (${response.status}) : ${await response.text()}`) } const uploaded = await response.json() return { uploaded, uploadPath } } async function markRowsExportSucceeded(db, rows, { markAsExported = false } = {}) { if (!rows.length) { return } const exportedColumnUpdate = markAsExported ? "exported_to_excel = 1," : "" await db.query( ` UPDATE projet_lycee_demandes SET ${exportedColumnUpdate} exported_to_excel_at = NOW(), export_error = NULL WHERE id IN (?) `, [rows.map((row) => row.id)] ) } async function markRowsExportFailed(db, rows, error) { if (!rows.length) { return } await db.query( ` UPDATE projet_lycee_demandes SET exported_to_excel_at = NOW(), export_error = ? WHERE id IN (?) `, [String(error?.message || error).slice(0, 5000), rows.map((row) => row.id)] ) } async function main() { loadDotEnv() const mode = optionalEnv("LYCEE_EXCEL_EXPORT_MODE", "all") if (!["all", "pending"].includes(mode)) { throw new Error("LYCEE_EXCEL_EXPORT_MODE doit valoir 'all' ou 'pending'.") } const db = await mysql.createConnection({ host: requireEnv("NUXT_MYSQL_HOST"), port: Number(optionalEnv("NUXT_MYSQL_PORT", "3306")), database: requireEnv("NUXT_MYSQL_DATABASE"), user: requireEnv("NUXT_MYSQL_USER"), password: requireEnv("NUXT_MYSQL_PASSWORD"), charset: "utf8mb4", }) let rows = [] try { rows = await getRows(db, mode) const buffer = await createXlsxBuffer(rows) const localOutputPath = resolveDatedPath(optionalEnv("LYCEE_EXCEL_LOCAL_OUTPUT", "")) let absoluteOutputPath = "" if (localOutputPath) { absoluteOutputPath = path.resolve(ROOT_DIR, localOutputPath) fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true }) fs.writeFileSync(absoluteOutputPath, buffer) console.log(`Fichier local généré : ${absoluteOutputPath}`) } if (optionalEnv("LYCEE_EXCEL_SKIP_UPLOAD", "") === "1") { console.log(`Upload Microsoft 365 ignoré. ${rows.length} ligne(s) générée(s).`) await trySendExportReport({ ok: true, mode, rowsCount: rows.length, localOutputPath: absoluteOutputPath, skippedUpload: true, }) return } const { uploaded, uploadPath } = await uploadToMicrosoft365(buffer) await markRowsExportSucceeded(db, rows, { markAsExported: mode === "pending" }) await trySendExportReport({ ok: true, mode, rowsCount: rows.length, localOutputPath: absoluteOutputPath, uploadPath, uploadedName: uploaded.name || uploaded.id, }) console.log(`${rows.length} demande(s) exportée(s) vers Microsoft 365 : ${uploaded.name || uploaded.id}`) } catch (error) { await markRowsExportFailed(db, rows, error) throw error } finally { await db.end() } } main().catch(async (error) => { console.error(error) try { loadDotEnv() await sendExportReport({ ok: false, mode: optionalEnv("LYCEE_EXCEL_EXPORT_MODE", "all"), error, }) } catch (mailError) { console.error("Erreur envoi email de rapport :", mailError) } process.exit(1) })