From 663c57306d5671764a32a05a0392f1fb8883a35a Mon Sep 17 00:00:00 2001 From: Julie Date: Tue, 16 Jun 2026 10:33:35 +0200 Subject: [PATCH] API Microsoft --- README.md | 67 +++ nuxt.config.js | 21 +- package.json | 2 + scripts/export-academie-excel.js | 486 ++++++++++++++++++ scripts/find-sharepoint-drive.js | 192 +++++++ server/api/projet-academie.post.js | 4 +- .../2026-06-15_academie_excel_export.sql | 4 + ...6-06-15_rename_academie_current_fields.sql | 3 + .../academie_orchestre_candidatures.sql | 7 +- server/utils/mailer.js | 75 +-- tmp/inscriptions-academie-2026-06-15.xlsx | Bin 0 -> 6453 bytes tmp/inscriptions-academie-2026-06-16.xlsx | Bin 0 -> 3716 bytes 12 files changed, 821 insertions(+), 40 deletions(-) create mode 100644 scripts/export-academie-excel.js create mode 100644 scripts/find-sharepoint-drive.js create mode 100644 server/database/2026-06-15_academie_excel_export.sql create mode 100644 server/database/2026-06-15_rename_academie_current_fields.sql create mode 100644 tmp/inscriptions-academie-2026-06-15.xlsx create mode 100644 tmp/inscriptions-academie-2026-06-16.xlsx 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 0000000000000000000000000000000000000000..fce2f6f27cd67a74dac47c222b95305bc9ba2e51 GIT binary patch literal 6453 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 Mce_JNbpP&u0JUIT+W-In literal 0 HcmV?d00001 diff --git a/tmp/inscriptions-academie-2026-06-16.xlsx b/tmp/inscriptions-academie-2026-06-16.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ef6e0d14af581c66338951dd6f3aa4264c3b9cfc GIT binary patch literal 3716 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 E34f9jssI20 literal 0 HcmV?d00001