generated from gitea_admin/default
API Microsoft
This commit is contained in:
67
README.md
67
README.md
@@ -246,3 +246,70 @@ Array.from(document.querySelectorAll('body > *')).forEach(el => {
|
|||||||
el.style.outline = '1px solid red';
|
el.style.outline = '1px solid red';
|
||||||
el.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'; // Utilisation de rgba pour une semi-transparence
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -56,13 +56,20 @@ export default defineNuxtConfig({
|
|||||||
parcRequestRecipientEmail: '',
|
parcRequestRecipientEmail: '',
|
||||||
scolaireRequestRecipientEmail: '',
|
scolaireRequestRecipientEmail: '',
|
||||||
academieRequestRecipientEmail: '',
|
academieRequestRecipientEmail: '',
|
||||||
smtpHost: '',
|
smtpHostParc: '',
|
||||||
smtpPort: '',
|
smtpPortParc: '',
|
||||||
smtpSecure: '',
|
smtpSecureParc: '',
|
||||||
smtpUser: '',
|
smtpUserParc: '',
|
||||||
smtpPassword: '',
|
smtpPasswordParc: '',
|
||||||
smtpFromEmail: '',
|
smtpFromEmailParc: '',
|
||||||
smtpFromName: '',
|
smtpFromNameParc: '',
|
||||||
|
smtpHostAec: '',
|
||||||
|
smtpPortAec: '',
|
||||||
|
smtpSecureAec: '',
|
||||||
|
smtpUserAec: '',
|
||||||
|
smtpPasswordAec: '',
|
||||||
|
smtpFromEmailAec: '',
|
||||||
|
smtpFromNameAec: '',
|
||||||
//instagramAppId: process.env.NUXT_INSTAGRAM_APP_ID,
|
//instagramAppId: process.env.NUXT_INSTAGRAM_APP_ID,
|
||||||
//instagramClientToken: process.env.NUXT_INSTAGRAM_CLIENT_TOKEN,
|
//instagramClientToken: process.env.NUXT_INSTAGRAM_CLIENT_TOKEN,
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
|
"export:academie-excel": "node scripts/export-academie-excel.js",
|
||||||
|
"graph:find-drive": "node scripts/find-sharepoint-drive.js",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"start": "node .output/server/index.mjs",
|
"start": "node .output/server/index.mjs",
|
||||||
|
|||||||
486
scripts/export-academie-excel.js
Normal file
486
scripts/export-academie-excel.js
Normal file
@@ -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 `<c r="${reference}" t="inlineStr"><is><t>${escapeXml(value)}</t></is></c>`
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
return `<row r="${rowNumber}">${cells}</row>`
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
const lastColumn = columnName(columns.length - 1)
|
||||||
|
const lastRow = Math.max(allRows.length, 1)
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<dimension ref="A1:${lastColumn}${lastRow}"/>
|
||||||
|
<sheetViews><sheetView workbookViewId="0"/></sheetViews>
|
||||||
|
<sheetFormatPr defaultRowHeight="15"/>
|
||||||
|
<cols>${columns.map((_, index) => `<col min="${index + 1}" max="${index + 1}" width="22" customWidth="1"/>`).join("")}</cols>
|
||||||
|
<sheetData>${sheetRows}</sheetData>
|
||||||
|
<autoFilter ref="A1:${lastColumn}${lastRow}"/>
|
||||||
|
</worksheet>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||||||
|
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||||
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||||
|
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||||
|
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||||
|
</Types>`, { name: "[Content_Types].xml" })
|
||||||
|
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||||
|
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||||||
|
</Relationships>`, { name: "_rels/.rels" })
|
||||||
|
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<sheets><sheet name="Inscriptions" sheetId="1" r:id="rId1"/></sheets>
|
||||||
|
</workbook>`, { name: "xl/workbook.xml" })
|
||||||
|
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
</Relationships>`, { name: "xl/_rels/workbook.xml.rels" })
|
||||||
|
archive.append(buildSheetXml(rows), { name: "xl/worksheets/sheet1.xml" })
|
||||||
|
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||||
|
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||||
|
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||||
|
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||||
|
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||||
|
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||||
|
</styleSheet>`, { name: "xl/styles.xml" })
|
||||||
|
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||||
|
<Application>wondif_vue</Application>
|
||||||
|
</Properties>`, { name: "docProps/app.xml" })
|
||||||
|
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
<dc:title>Inscriptions Académie d'Orchestre</dc:title>
|
||||||
|
<dc:creator>wondif_vue</dc:creator>
|
||||||
|
<cp:lastModifiedBy>wondif_vue</cp:lastModifiedBy>
|
||||||
|
<dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:created>
|
||||||
|
<dcterms:modified xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:modified>
|
||||||
|
</cp:coreProperties>`, { 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)
|
||||||
|
})
|
||||||
192
scripts/find-sharepoint-drive.js
Normal file
192
scripts/find-sharepoint-drive.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
@@ -185,9 +185,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
instrument,
|
instrument,
|
||||||
deja_participe_academie,
|
deja_participe_academie,
|
||||||
nombre_participations_academie,
|
nombre_participations_academie,
|
||||||
niveau_cycle_2026_2027,
|
niveau_cycle_actuel,
|
||||||
autre_formation,
|
autre_formation,
|
||||||
conservatoire_2026_2027,
|
conservatoire_actuel,
|
||||||
autre_conservatoire,
|
autre_conservatoire,
|
||||||
professeur_instrument,
|
professeur_instrument,
|
||||||
diplome_1_type,
|
diplome_1_type,
|
||||||
|
|||||||
4
server/database/2026-06-15_academie_excel_export.sql
Normal file
4
server/database/2026-06-15_academie_excel_export.sql
Normal file
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -15,9 +15,9 @@ CREATE TABLE academie_orchestre_candidatures (
|
|||||||
instrument VARCHAR(255) NOT NULL,
|
instrument VARCHAR(255) NOT NULL,
|
||||||
deja_participe_academie VARCHAR(10) NOT NULL,
|
deja_participe_academie VARCHAR(10) NOT NULL,
|
||||||
nombre_participations_academie INT UNSIGNED 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,
|
autre_formation VARCHAR(255) NULL,
|
||||||
conservatoire_2026_2027 VARCHAR(500) NOT NULL,
|
conservatoire_actuel VARCHAR(500) NOT NULL,
|
||||||
autre_conservatoire VARCHAR(500) NULL,
|
autre_conservatoire VARCHAR(500) NULL,
|
||||||
professeur_instrument VARCHAR(255) NOT NULL,
|
professeur_instrument VARCHAR(255) NOT NULL,
|
||||||
diplome_1_type VARCHAR(255) 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_telephone VARCHAR(100) NOT NULL,
|
||||||
contact_urgence_email VARCHAR(255) NOT NULL,
|
contact_urgence_email VARCHAR(255) NOT NULL,
|
||||||
reglement_accepte TINYINT(1) NOT NULL DEFAULT 0,
|
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,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
@@ -1,42 +1,62 @@
|
|||||||
import nodemailer from "nodemailer"
|
import nodemailer from "nodemailer"
|
||||||
|
|
||||||
let transporter
|
const transporters = {}
|
||||||
|
|
||||||
function isSmtpConfigured(config) {
|
function isSmtpConfigured(config) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
config.smtpHost &&
|
config.host &&
|
||||||
config.smtpPort &&
|
config.port &&
|
||||||
config.smtpUser &&
|
config.user &&
|
||||||
config.smtpPassword &&
|
config.password &&
|
||||||
config.smtpFromEmail
|
config.fromEmail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransporter() {
|
function getSmtpConfig(config, suffix) {
|
||||||
if (transporter) {
|
const key = suffix.charAt(0).toUpperCase() + suffix.slice(1)
|
||||||
return transporter
|
|
||||||
|
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)) {
|
if (!isSmtpConfigured(config)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
transporter = nodemailer.createTransport({
|
transporters[suffix] = nodemailer.createTransport({
|
||||||
host: config.smtpHost,
|
host: config.host,
|
||||||
port: Number(config.smtpPort),
|
port: Number(config.port),
|
||||||
secure: String(config.smtpSecure).toLowerCase() === "true",
|
secure: String(config.secure).toLowerCase() === "true",
|
||||||
connectionTimeout: 10000,
|
connectionTimeout: 10000,
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 10000,
|
||||||
socketTimeout: 15000,
|
socketTimeout: 15000,
|
||||||
auth: {
|
auth: {
|
||||||
user: config.smtpUser,
|
user: config.user,
|
||||||
pass: config.smtpPassword,
|
pass: config.password,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return transporter
|
return transporters[suffix]
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -50,15 +70,14 @@ function escapeHtml(value) {
|
|||||||
|
|
||||||
export async function sendParcDemandEmails(payload) {
|
export async function sendParcDemandEmails(payload) {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const mailer = getTransporter()
|
const smtpConfig = getSmtpConfig(config, "parc")
|
||||||
|
const mailer = getTransporter("parc")
|
||||||
|
|
||||||
if (!mailer || !config.parcRequestRecipientEmail) {
|
if (!mailer || !config.parcRequestRecipientEmail) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = config.smtpFromName
|
const from = getFrom(smtpConfig)
|
||||||
? `"${config.smtpFromName}" <${config.smtpFromEmail}>`
|
|
||||||
: config.smtpFromEmail
|
|
||||||
|
|
||||||
const adminSubject = `WONDIF - Formulaire de demande Parc instrumental - ${payload.name}`
|
const adminSubject = `WONDIF - Formulaire de demande Parc instrumental - ${payload.name}`
|
||||||
const adminText = [
|
const adminText = [
|
||||||
@@ -144,7 +163,8 @@ function getProjetLyceeSummary(payload) {
|
|||||||
|
|
||||||
export async function sendProjetLyceeEmails(payload) {
|
export async function sendProjetLyceeEmails(payload) {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const mailer = getTransporter()
|
const smtpConfig = getSmtpConfig(config, "aec")
|
||||||
|
const mailer = getTransporter("aec")
|
||||||
|
|
||||||
if (!mailer) {
|
if (!mailer) {
|
||||||
console.warn("Email projet lycée non envoyé: configuration SMTP incomplète.")
|
console.warn("Email projet lycée non envoyé: configuration SMTP incomplète.")
|
||||||
@@ -156,9 +176,7 @@ export async function sendProjetLyceeEmails(payload) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = config.smtpFromName
|
const from = getFrom(smtpConfig)
|
||||||
? `"${config.smtpFromName}" <${config.smtpFromEmail}>`
|
|
||||||
: config.smtpFromEmail
|
|
||||||
|
|
||||||
const summary = getProjetLyceeSummary(payload)
|
const summary = getProjetLyceeSummary(payload)
|
||||||
|
|
||||||
@@ -262,7 +280,8 @@ function getProjetAcademieSummary(payload) {
|
|||||||
|
|
||||||
export async function sendProjetAcademieEmails(payload) {
|
export async function sendProjetAcademieEmails(payload) {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const mailer = getTransporter()
|
const smtpConfig = getSmtpConfig(config, "aec")
|
||||||
|
const mailer = getTransporter("aec")
|
||||||
|
|
||||||
if (!mailer) {
|
if (!mailer) {
|
||||||
console.warn("Email projet académie non envoyé: configuration SMTP incomplète.")
|
console.warn("Email projet académie non envoyé: configuration SMTP incomplète.")
|
||||||
@@ -274,9 +293,7 @@ export async function sendProjetAcademieEmails(payload) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = config.smtpFromName
|
const from = getFrom(smtpConfig)
|
||||||
? `"${config.smtpFromName}" <${config.smtpFromEmail}>`
|
|
||||||
: config.smtpFromEmail
|
|
||||||
|
|
||||||
const summary = getProjetAcademieSummary(payload)
|
const summary = getProjetAcademieSummary(payload)
|
||||||
const fullName = `${payload.lastName} ${payload.firstName}`
|
const fullName = `${payload.lastName} ${payload.firstName}`
|
||||||
|
|||||||
BIN
tmp/inscriptions-academie-2026-06-15.xlsx
Normal file
BIN
tmp/inscriptions-academie-2026-06-15.xlsx
Normal file
Binary file not shown.
BIN
tmp/inscriptions-academie-2026-06-16.xlsx
Normal file
BIN
tmp/inscriptions-academie-2026-06-16.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user