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