diff --git a/README.md b/README.md index 0ee1c79..b4e30d0 100644 --- a/README.md +++ b/README.md @@ -246,3 +246,70 @@ Array.from(document.querySelectorAll('body > *')).forEach(el => { el.style.outline = '1px solid red'; el.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'; // Utilisation de rgba pour une semi-transparence }); + +## Export Excel des inscriptions Académie + +Le script `scripts/export-academie-excel.js` génère un fichier `.xlsx` depuis la table MySQL `academie_orchestre_candidatures`, puis l'envoie dans Microsoft 365 via Microsoft Graph. + +### Migration SQL + +À exécuter une fois sur la base : + +```sql +source server/database/2026-06-15_academie_excel_export.sql; +source server/database/2026-06-15_rename_academie_current_fields.sql; +``` + +### Variables d'environnement + +Variables MySQL déjà utilisées par Nuxt : + +```env +NUXT_MYSQL_HOST=... +NUXT_MYSQL_PORT=3306 +NUXT_MYSQL_DATABASE=... +NUXT_MYSQL_USER=... +NUXT_MYSQL_PASSWORD=... +``` + +Variables Microsoft Graph à ajouter : + +```env +MICROSOFT_TENANT_ID=... +MICROSOFT_CLIENT_ID=... +MICROSOFT_CLIENT_SECRET=... +GRAPH_DRIVE_ID=... +GRAPH_EXCEL_UPLOAD_PATH=Académie d'orchestre/Candidatures/inscriptions-academie-{date}.xlsx +ACADEMIE_EXCEL_REPORT_EMAIL=... +``` + +Optionnel : + +```env +GRAPH_EXCEL_ITEM_ID=... +ACADEMIE_EXCEL_EXPORT_MODE=all +ACADEMIE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-academie-{date}.xlsx +ACADEMIE_EXCEL_SKIP_UPLOAD=1 +``` + +Modes : + +- `ACADEMIE_EXCEL_EXPORT_MODE=all` : exporte toutes les inscriptions dans un fichier complet. Mode recommandé pour commencer. +- `ACADEMIE_EXCEL_EXPORT_MODE=pending` : exporte seulement les lignes non exportées, puis les marque comme exportées. +- `{date}` dans `GRAPH_EXCEL_UPLOAD_PATH` ou `ACADEMIE_EXCEL_LOCAL_OUTPUT` est remplacé par la date du jour au format `YYYY-MM-DD`, par exemple `inscriptions-academie-2026-06-15.xlsx`. +- Ne renseigne pas `GRAPH_EXCEL_ITEM_ID` si tu veux créer un nouveau fichier daté chaque jour : cette variable sert à remplacer un fichier SharePoint existant. +- `ACADEMIE_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 +ACADEMIE_EXCEL_SKIP_UPLOAD=1 ACADEMIE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-academie-{date}.xlsx npm run export:academie-excel +``` + +### Cron nocturne + +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 +``` diff --git a/nuxt.config.js b/nuxt.config.js index 1b26bda..0091205 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -56,13 +56,20 @@ export default defineNuxtConfig({ parcRequestRecipientEmail: '', scolaireRequestRecipientEmail: '', academieRequestRecipientEmail: '', - smtpHost: '', - smtpPort: '', - smtpSecure: '', - smtpUser: '', - smtpPassword: '', - smtpFromEmail: '', - smtpFromName: '', + smtpHostParc: '', + smtpPortParc: '', + smtpSecureParc: '', + smtpUserParc: '', + smtpPasswordParc: '', + smtpFromEmailParc: '', + smtpFromNameParc: '', + smtpHostAec: '', + smtpPortAec: '', + smtpSecureAec: '', + smtpUserAec: '', + smtpPasswordAec: '', + smtpFromEmailAec: '', + smtpFromNameAec: '', //instagramAppId: process.env.NUXT_INSTAGRAM_APP_ID, //instagramClientToken: process.env.NUXT_INSTAGRAM_CLIENT_TOKEN, diff --git a/package.json b/package.json index 5ce300b..cce6051 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "scripts": { "build": "nuxt build", "dev": "nuxt dev", + "export:academie-excel": "node scripts/export-academie-excel.js", + "graph:find-drive": "node scripts/find-sharepoint-drive.js", "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", diff --git a/scripts/export-academie-excel.js b/scripts/export-academie-excel.js new file mode 100644 index 0000000..284a854 --- /dev/null +++ b/scripts/export-academie-excel.js @@ -0,0 +1,486 @@ +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) +}) diff --git a/scripts/find-sharepoint-drive.js b/scripts/find-sharepoint-drive.js new file mode 100644 index 0000000..491d218 --- /dev/null +++ b/scripts/find-sharepoint-drive.js @@ -0,0 +1,192 @@ +import fs from "node:fs" +import path from "node:path" +import process from "node:process" + +const ROOT_DIR = process.cwd() + +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 +} + +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 +} + +function decodeJwtPayload(token) { + const [, payload] = token.split(".") + + if (!payload) { + return {} + } + + const normalizedPayload = payload.replaceAll("-", "+").replaceAll("_", "/") + const paddedPayload = normalizedPayload.padEnd(normalizedPayload.length + ((4 - normalizedPayload.length % 4) % 4), "=") + + return JSON.parse(Buffer.from(paddedPayload, "base64").toString("utf8")) +} + +function parseSharePointSiteUrl(rawUrl) { + const url = new URL(rawUrl) + const normalizedPath = url.pathname.replace(/\/+$/, "") + + if (!normalizedPath) { + throw new Error("L'URL SharePoint doit contenir le chemin du site, par exemple https://tenant.sharepoint.com/sites/nom-du-site") + } + + return { + hostname: url.hostname, + sitePath: normalizedPath, + } +} + +async function graphGet(token, url) { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + if (!response.ok) { + const details = await response.text() + const hint = response.status === 401 + ? "\nVérifie que l'application Entra ID a des permissions Microsoft Graph de type Application, par exemple Sites.Read.All ou Sites.ReadWrite.All, avec Grant admin consent. Vérifie aussi que le tenant correspond bien au SharePoint." + : "" + const error = new Error(`Erreur Microsoft Graph (${response.status}) : ${details}${hint}`) + error.status = response.status + error.details = details + + throw error + } + + return response.json() +} + +async function main() { + loadDotEnv() + + const sharePointUrl = process.argv[2] || process.env.SHAREPOINT_SITE_URL + + if (!sharePointUrl) { + throw new Error("Usage : npm run graph:find-drive -- https://tenant.sharepoint.com/sites/nom-du-site") + } + + const token = await getAccessToken() + const claims = decodeJwtPayload(token) + + console.log(`Token Microsoft Graph obtenu pour tenant=${claims.tid || "inconnu"} app=${claims.appid || claims.azp || "inconnue"}`) + console.log(`Permissions applicatives dans le token : ${(claims.roles || []).join(", ") || "aucune"}`) + console.log("") + + const { hostname, sitePath } = parseSharePointSiteUrl(sharePointUrl) + let site + + try { + site = await graphGet( + token, + `https://graph.microsoft.com/v1.0/sites/${hostname}:${sitePath}` + ) + } catch (error) { + if (error.status !== 404) { + throw error + } + + const searchTerm = sitePath.split("/").filter(Boolean).at(-1) + console.log(`Site introuvable avec le chemin ${sitePath}. Recherche des sites contenant "${searchTerm}"...`) + console.log("") + + const searchResults = await graphGet( + token, + `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchTerm)}` + ) + + if (!searchResults.value?.length) { + throw error + } + + console.log("Sites trouvés :") + + for (const result of searchResults.value) { + console.log(`- ${result.displayName || result.name}`) + console.log(` SITE_ID=${result.id}`) + console.log(` webUrl=${result.webUrl}`) + } + + console.log("") + console.log("Relance ensuite la commande avec l'URL webUrl exacte du bon site.") + return + } + + const drives = await graphGet( + token, + `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(site.id)}/drives` + ) + + console.log(`SITE_ID=${site.id}`) + console.log("") + console.log("Bibliothèques SharePoint trouvées :") + + for (const drive of drives.value || []) { + console.log(`- ${drive.name}`) + console.log(` GRAPH_DRIVE_ID=${drive.id}`) + console.log(` webUrl=${drive.webUrl}`) + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/server/api/projet-academie.post.js b/server/api/projet-academie.post.js index 34f6886..dd0cedd 100644 --- a/server/api/projet-academie.post.js +++ b/server/api/projet-academie.post.js @@ -185,9 +185,9 @@ export default defineEventHandler(async (event) => { instrument, deja_participe_academie, nombre_participations_academie, - niveau_cycle_2026_2027, + niveau_cycle_actuel, autre_formation, - conservatoire_2026_2027, + conservatoire_actuel, autre_conservatoire, professeur_instrument, diplome_1_type, diff --git a/server/database/2026-06-15_academie_excel_export.sql b/server/database/2026-06-15_academie_excel_export.sql new file mode 100644 index 0000000..149e4cc --- /dev/null +++ b/server/database/2026-06-15_academie_excel_export.sql @@ -0,0 +1,4 @@ +ALTER TABLE academie_orchestre_candidatures + 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; diff --git a/server/database/2026-06-15_rename_academie_current_fields.sql b/server/database/2026-06-15_rename_academie_current_fields.sql new file mode 100644 index 0000000..01b6712 --- /dev/null +++ b/server/database/2026-06-15_rename_academie_current_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE academie_orchestre_candidatures + CHANGE COLUMN niveau_cycle_2026_2027 niveau_cycle_actuel VARCHAR(255) NOT NULL, + CHANGE COLUMN conservatoire_2026_2027 conservatoire_actuel VARCHAR(500) NOT NULL; diff --git a/server/database/academie_orchestre_candidatures.sql b/server/database/academie_orchestre_candidatures.sql index 90f4912..3d2fa70 100644 --- a/server/database/academie_orchestre_candidatures.sql +++ b/server/database/academie_orchestre_candidatures.sql @@ -15,9 +15,9 @@ CREATE TABLE academie_orchestre_candidatures ( instrument VARCHAR(255) NOT NULL, deja_participe_academie VARCHAR(10) NOT NULL, nombre_participations_academie INT UNSIGNED NULL, - niveau_cycle_2026_2027 VARCHAR(255) NOT NULL, + niveau_cycle_actuel VARCHAR(255) NOT NULL, autre_formation VARCHAR(255) NULL, - conservatoire_2026_2027 VARCHAR(500) NOT NULL, + conservatoire_actuel VARCHAR(500) NOT NULL, autre_conservatoire VARCHAR(500) NULL, professeur_instrument VARCHAR(255) NOT NULL, diplome_1_type VARCHAR(255) NULL, @@ -41,6 +41,9 @@ CREATE TABLE academie_orchestre_candidatures ( contact_urgence_telephone VARCHAR(100) NOT NULL, contact_urgence_email VARCHAR(255) NOT NULL, reglement_accepte TINYINT(1) NOT NULL DEFAULT 0, + exported_to_excel TINYINT(1) NOT NULL DEFAULT 0, + exported_to_excel_at DATETIME NULL, + export_error TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/utils/mailer.js b/server/utils/mailer.js index 4e19add..8a75588 100644 --- a/server/utils/mailer.js +++ b/server/utils/mailer.js @@ -1,42 +1,62 @@ import nodemailer from "nodemailer" -let transporter +const transporters = {} function isSmtpConfigured(config) { return Boolean( - config.smtpHost && - config.smtpPort && - config.smtpUser && - config.smtpPassword && - config.smtpFromEmail + config.host && + config.port && + config.user && + config.password && + config.fromEmail ) } -function getTransporter() { - if (transporter) { - return transporter +function getSmtpConfig(config, suffix) { + const key = suffix.charAt(0).toUpperCase() + suffix.slice(1) + + return { + host: config[`smtpHost${key}`], + port: config[`smtpPort${key}`], + secure: config[`smtpSecure${key}`], + user: config[`smtpUser${key}`], + password: config[`smtpPassword${key}`], + fromEmail: config[`smtpFromEmail${key}`], + fromName: config[`smtpFromName${key}`], + } +} + +function getFrom(config) { + return config.fromName + ? `"${config.fromName}" <${config.fromEmail}>` + : config.fromEmail +} + +function getTransporter(suffix) { + if (transporters[suffix]) { + return transporters[suffix] } - const config = useRuntimeConfig() + const config = getSmtpConfig(useRuntimeConfig(), suffix) if (!isSmtpConfigured(config)) { return null } - transporter = nodemailer.createTransport({ - host: config.smtpHost, - port: Number(config.smtpPort), - secure: String(config.smtpSecure).toLowerCase() === "true", + transporters[suffix] = nodemailer.createTransport({ + host: config.host, + port: Number(config.port), + secure: String(config.secure).toLowerCase() === "true", connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 15000, auth: { - user: config.smtpUser, - pass: config.smtpPassword, + user: config.user, + pass: config.password, }, }) - return transporter + return transporters[suffix] } function escapeHtml(value) { @@ -50,15 +70,14 @@ function escapeHtml(value) { export async function sendParcDemandEmails(payload) { const config = useRuntimeConfig() - const mailer = getTransporter() + const smtpConfig = getSmtpConfig(config, "parc") + const mailer = getTransporter("parc") if (!mailer || !config.parcRequestRecipientEmail) { return false } - const from = config.smtpFromName - ? `"${config.smtpFromName}" <${config.smtpFromEmail}>` - : config.smtpFromEmail + const from = getFrom(smtpConfig) const adminSubject = `WONDIF - Formulaire de demande Parc instrumental - ${payload.name}` const adminText = [ @@ -144,7 +163,8 @@ function getProjetLyceeSummary(payload) { export async function sendProjetLyceeEmails(payload) { const config = useRuntimeConfig() - const mailer = getTransporter() + const smtpConfig = getSmtpConfig(config, "aec") + const mailer = getTransporter("aec") if (!mailer) { console.warn("Email projet lycée non envoyé: configuration SMTP incomplète.") @@ -156,9 +176,7 @@ export async function sendProjetLyceeEmails(payload) { return false } - const from = config.smtpFromName - ? `"${config.smtpFromName}" <${config.smtpFromEmail}>` - : config.smtpFromEmail + const from = getFrom(smtpConfig) const summary = getProjetLyceeSummary(payload) @@ -262,7 +280,8 @@ function getProjetAcademieSummary(payload) { export async function sendProjetAcademieEmails(payload) { const config = useRuntimeConfig() - const mailer = getTransporter() + const smtpConfig = getSmtpConfig(config, "aec") + const mailer = getTransporter("aec") if (!mailer) { console.warn("Email projet académie non envoyé: configuration SMTP incomplète.") @@ -274,9 +293,7 @@ export async function sendProjetAcademieEmails(payload) { return false } - const from = config.smtpFromName - ? `"${config.smtpFromName}" <${config.smtpFromEmail}>` - : config.smtpFromEmail + const from = getFrom(smtpConfig) const summary = getProjetAcademieSummary(payload) const fullName = `${payload.lastName} ${payload.firstName}` diff --git a/tmp/inscriptions-academie-2026-06-15.xlsx b/tmp/inscriptions-academie-2026-06-15.xlsx new file mode 100644 index 0000000..fce2f6f Binary files /dev/null and b/tmp/inscriptions-academie-2026-06-15.xlsx differ diff --git a/tmp/inscriptions-academie-2026-06-16.xlsx b/tmp/inscriptions-academie-2026-06-16.xlsx new file mode 100644 index 0000000..ef6e0d1 Binary files /dev/null and b/tmp/inscriptions-academie-2026-06-16.xlsx differ