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-academie-{date}.xlsx" const columns = [ ["id", "ID inscription"], ["created_at", "Date inscription"], ["nom", "Nom"], ["prenom", "Prénom"], ["genre", "Genre"], ["date_naissance", "Date naissance"], ["lieu_naissance", "Lieu naissance"], ["nationalite", "Nationalité"], ["email", "Email"], ["telephone", "Téléphone"], ["adresse", "Adresse"], ["ville", "Ville"], ["code_postal", "Code postal"], ["pays_residence", "Pays résidence"], ["instrument", "Instrument"], ["deja_participe_academie", "Déjà participé"], ["nombre_participations_academie", "Nombre participations"], ["niveau_cycle_actuel", "Niveau actuel"], ["conservatoire_actuel", "Conservatoire actuel"], ["professeur_instrument", "Professeur"], ["diplome_1_type", "Diplôme 1 type"], ["diplome_1_discipline", "Diplôme 1 discipline"], ["diplome_1_annee", "Diplôme 1 année"], ["diplome_1_etablissement", "Diplôme 1 établissement"], ["diplome_2_type", "Diplôme 2 type"], ["diplome_2_discipline", "Diplôme 2 discipline"], ["diplome_2_annee", "Diplôme 2 année"], ["diplome_2_etablissement", "Diplôme 2 établissement"], ["diplome_3_type", "Diplôme 3 type"], ["diplome_3_discipline", "Diplôme 3 discipline"], ["diplome_3_annee", "Diplôme 3 année"], ["diplome_3_etablissement", "Diplôme 3 établissement"], ["contact_urgence_nom", "Contact urgence nom"], ["contact_urgence_prenom", "Contact urgence prénom"], ["contact_urgence_lien", "Contact urgence lien"], ["contact_urgence_telephone", "Contact urgence téléphone"], ["contact_urgence_email", "Contact urgence email"], ] 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 Académie OK - ${formatLocalDate()}` : `Export Académie ERREUR - ${formatLocalDate()}` const text = ok ? [ "L'export Excel des inscriptions Académie s'est terminé correctement.", "", `Mode : ${mode}`, `Inscriptions 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 inscriptions Académie 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(` Inscriptions Académie d'Orchestre 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 extraColumns = [ "autre_formation", "autre_conservatoire", "diplome_1_autre_type", "diplome_2_autre_type", "diplome_3_autre_type", ] const selectColumns = [...new Set([...columns.map(([key]) => key), ...extraColumns])].join(", ") const [rows] = await db.execute(` SELECT ${selectColumns} FROM academie_orchestre_candidatures ${where} ORDER BY created_at ASC, id ASC `) return rows.map((row) => ({ ...row, niveau_cycle_actuel: row.niveau_cycle_actuel === "Autre" ? row.autre_formation : row.niveau_cycle_actuel, conservatoire_actuel: row.conservatoire_actuel === "Autre" ? row.autre_conservatoire : row.conservatoire_actuel, diplome_1_type: row.diplome_1_type === "Autre" ? row.diplome_1_autre_type : row.diplome_1_type, diplome_2_type: row.diplome_2_type === "Autre" ? row.diplome_2_autre_type : row.diplome_2_type, diplome_3_type: row.diplome_3_type === "Autre" ? row.diplome_3_autre_type : row.diplome_3_type, })) } 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("GRAPH_EXCEL_ITEM_ID") const uploadPath = resolveDatedPath(optionalEnv("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 markRowsAsExported(db, rows) { if (!rows.length) { return } await db.query( ` UPDATE academie_orchestre_candidatures SET exported_to_excel = 1, exported_to_excel_at = NOW(), export_error = NULL WHERE id IN (?) `, [rows.map((row) => row.id)] ) } async function main() { loadDotEnv() const mode = optionalEnv("ACADEMIE_EXCEL_EXPORT_MODE", "all") if (!["all", "pending"].includes(mode)) { throw new Error("ACADEMIE_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", }) try { const rows = await getRows(db, mode) const buffer = await createXlsxBuffer(rows) const localOutputPath = resolveDatedPath(optionalEnv("ACADEMIE_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("ACADEMIE_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) if (mode === "pending") { await markRowsAsExported(db, rows) } await trySendExportReport({ ok: true, mode, rowsCount: rows.length, localOutputPath: absoluteOutputPath, uploadPath, uploadedName: uploaded.name || uploaded.id, }) console.log(`${rows.length} inscription(s) exportée(s) vers Microsoft 365 : ${uploaded.name || uploaded.id}`) } finally { await db.end() } } main().catch(async (error) => { console.error(error) try { loadDotEnv() await sendExportReport({ ok: false, mode: optionalEnv("ACADEMIE_EXCEL_EXPORT_MODE", "all"), error, }) } catch (mailError) { console.error("Erreur envoi email de rapport :", mailError) } process.exit(1) })