diff --git a/README.md b/README.md index b4e30d0..7913ca1 100644 --- a/README.md +++ b/README.md @@ -313,3 +313,69 @@ Exemple pour un export tous les jours à 02:00 : ```bash 0 2 * * * cd /var/www/ton-projet && npm run export:academie-excel >> /var/log/academy-export.log 2>&1 ``` + +## Export Excel des demandes projet lycée + +Le script `scripts/export-lycee-excel.js` génère un fichier `.xlsx` depuis la table MySQL `projet_lycee_demandes`, puis l'envoie dans Microsoft 365 via Microsoft Graph. + +### Migration SQL + +À exécuter une fois sur la base si tu veux utiliser le mode `pending` : + +```sql +source server/database/2026-06-16_lycee_excel_export.sql; +``` + +### Variables d'environnement + +Les variables MySQL et Microsoft Graph communes sont les mêmes que pour l'export Académie : + +```env +NUXT_MYSQL_HOST=... +NUXT_MYSQL_PORT=3306 +NUXT_MYSQL_DATABASE=... +NUXT_MYSQL_USER=... +NUXT_MYSQL_PASSWORD=... +MICROSOFT_TENANT_ID=... +MICROSOFT_CLIENT_ID=... +MICROSOFT_CLIENT_SECRET=... +GRAPH_DRIVE_ID=... +``` + +Variables lycée à ajouter : + +```env +LYCEE_GRAPH_EXCEL_UPLOAD_PATH=Projets lycee/Demandes/inscriptions-lycee-{date}.xlsx +LYCEE_EXCEL_REPORT_EMAIL=... +``` + +Optionnel : + +```env +LYCEE_GRAPH_EXCEL_ITEM_ID=... +LYCEE_EXCEL_EXPORT_MODE=all +LYCEE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-lycee-{date}.xlsx +LYCEE_EXCEL_SKIP_UPLOAD=1 +``` + +Modes : + +- `LYCEE_EXCEL_EXPORT_MODE=all` : exporte toutes les demandes dans un fichier complet, puis met à jour `exported_to_excel_at` et `export_error`. +- `LYCEE_EXCEL_EXPORT_MODE=pending` : exporte seulement les lignes non exportées, puis met aussi `exported_to_excel` à `1`. Nécessite la migration SQL ci-dessus. +- En cas d'échec après lecture des demandes, `export_error` est renseigné sur les lignes concernées. +- `{date}` dans `LYCEE_GRAPH_EXCEL_UPLOAD_PATH` ou `LYCEE_EXCEL_LOCAL_OUTPUT` est remplacé par la date du jour au format `YYYY-MM-DD`. +- `LYCEE_EXCEL_REPORT_EMAIL` reçoit un email de compte rendu après chaque export, ou un email d'erreur si le traitement échoue. + +### Test local sans upload + +```bash +LYCEE_EXCEL_SKIP_UPLOAD=1 LYCEE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-lycee-{date}.xlsx npm run export:lycee-excel +``` + +### Cron nocturne + +Exemple pour un export tous les jours à 02:15 : + +```bash +15 2 * * * cd /var/www/ton-projet && npm run export:lycee-excel >> /var/log/lycee-export.log 2>&1 +``` diff --git a/app/pages/concerts/saison.vue b/app/pages/concerts/saison.vue index 72aa214..c5a7bcc 100644 --- a/app/pages/concerts/saison.vue +++ b/app/pages/concerts/saison.vue @@ -68,7 +68,7 @@ const { items: seasonSource } = useStrapi("/api/__strapi__/concerts", { locale: "fr-FR", - pageSize: 200, + pageSize: 400, populate: { saison_concert: true, }, @@ -102,6 +102,7 @@ const { concerts, refresh } = useConcerts({ locale: "fr-FR", + pageSize: 400, populate: { saison_concert: true, genre_concert: true, diff --git a/app/pages/index.vue b/app/pages/index.vue index 0d95572..e1e188c 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -320,9 +320,9 @@ }, { id: '2', - imgSrc: '/contenus/academie_orchestre_26.jpg', - imgAlt: "Académie d'Orchestre 2026", - title: "Académie d'Orchestre 2026", + imgSrc: '/contenus/academie_web_muet.jpg', + imgAlt: "Académie d'Orchestre 2027", + title: "Académie d'Orchestre 2027", description: ``, url:"https://www.orchestre-ile.com/mediation/academie", }, diff --git a/package.json b/package.json index cce6051..bbc470e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "nuxt build", "dev": "nuxt dev", "export:academie-excel": "node scripts/export-academie-excel.js", + "export:lycee-excel": "node scripts/export-lycee-excel.js", "graph:find-drive": "node scripts/find-sharepoint-drive.js", "generate": "nuxt generate", "preview": "nuxt preview", diff --git a/public/contenus/academie_web_muet.jpg b/public/contenus/academie_web_muet.jpg new file mode 100644 index 0000000..fb09b71 Binary files /dev/null and b/public/contenus/academie_web_muet.jpg differ diff --git a/scripts/export-lycee-excel.js b/scripts/export-lycee-excel.js new file mode 100644 index 0000000..66b8308 --- /dev/null +++ b/scripts/export-lycee-excel.js @@ -0,0 +1,472 @@ +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) +}) diff --git a/server/database/2026-06-16_lycee_excel_export.sql b/server/database/2026-06-16_lycee_excel_export.sql new file mode 100644 index 0000000..ca67155 --- /dev/null +++ b/server/database/2026-06-16_lycee_excel_export.sql @@ -0,0 +1,5 @@ +ALTER TABLE projet_lycee_demandes + ADD COLUMN exported_to_excel TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN exported_to_excel_at DATETIME NULL, + ADD COLUMN export_error TEXT NULL, + ADD INDEX idx_projet_lycee_exported_to_excel (exported_to_excel, created_at); diff --git a/server/utils/mailer.js b/server/utils/mailer.js index 8a75588..bc6e7a3 100644 --- a/server/utils/mailer.js +++ b/server/utils/mailer.js @@ -357,7 +357,6 @@ export async function sendProjetAcademieEmails(payload) { mailer.sendMail({ from, to: config.academieRequestRecipientEmail, - replyTo: payload.email, subject: adminSubject, text: adminText, }), diff --git a/tmp/inscriptions-lycee-2026-06-16.xlsx b/tmp/inscriptions-lycee-2026-06-16.xlsx new file mode 100644 index 0000000..4abbd8d Binary files /dev/null and b/tmp/inscriptions-lycee-2026-06-16.xlsx differ diff --git a/tmp/inscriptions-lycee-2026-06-17.xlsx b/tmp/inscriptions-lycee-2026-06-17.xlsx new file mode 100644 index 0000000..5a258eb Binary files /dev/null and b/tmp/inscriptions-lycee-2026-06-17.xlsx differ