feat(aep): carte AEP — push Gitea 2026-04-28

This commit is contained in:
Jules Neny
2026-04-28 14:00:05 +02:00
commit 21c44d8193
86 changed files with 31855 additions and 0 deletions

234
worker/daily-digest.js Normal file
View File

@@ -0,0 +1,234 @@
#!/usr/bin/env node
// worker/daily-digest.js — Email récap quotidien nouvelles fiches AEP
// Cron : 0 8 * * * (tous les jours à 8h)
// Usage : node worker/daily-digest.js
import 'dotenv/config'
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
const NOCODB_BASE = process.env.NOCODB_BASE;
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const RESEND_FROM = process.env.RESEND_FROM || 'AEP Digest <noreply@trans-former.fr>';
const EMAIL_DEST = process.env.EMAIL_JULES || 'transformationsresilientes@gmail.com';
const NOCODB_ADMIN_URL = process.env.NOCODB_ADMIN_URL || 'http://localhost:8070';
// ─── UTILITAIRES LOG ─────────────────────────────────────────────────────────
function log(...args) {
const ts = new Date().toISOString();
console.log(`[${ts}]`, ...args);
}
// ─── NOCODB — FETCH FICHES DERNIÈRES 24H ─────────────────────────────────────
async function fetchNewFiches() {
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
// NocoDB ne supporte pas bien le filtre datetime sur CreatedAt — on récupère
// les 200 dernières fiches triées par date décroissante et on filtre en JS
// (même pattern que getBudgetMoisCourant dans enrich.js)
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE}/${NOCODB_TABLE_ORGAS}?limit=200&sort=-CreatedAt`;
const res = await fetch(url, {
headers: { 'xc-token': NOCODB_TOKEN }
});
if (!res.ok) {
throw new Error(`NocoDB GET fiches → ${res.status}: ${await res.text()}`);
}
const data = await res.json();
const rows = data.list || [];
// Filtre JS sur les 24 dernières heures
const recent = rows.filter(row => {
const created = new Date(row.CreatedAt || row.created_at || 0);
return created >= new Date(since);
});
return recent;
}
// ─── CONSTRUCTION EMAIL HTML ─────────────────────────────────────────────────
function buildEmailHtml(fiches) {
const count = fiches.length;
const date = new Date().toLocaleDateString('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
});
const lignes = fiches.map(f => {
const nom = f.nom || '(sans nom)';
const url = f.url || null;
const echelle = f.echelle || '—';
const statut = f.moderation_status || 'pending';
const ficheUrl = `https://aep.trans-former.fr/fiche/${f.Id}`;
const nomHtml = url
? `<a href="${url}" style="color:#1a56db;text-decoration:none;">${escHtml(nom)}</a>`
: escHtml(nom);
const statutColor = statut === 'approved' ? '#057a55'
: statut === 'rejected' ? '#c81e1e'
: statut === 'ai_processed' ? '#1a56db'
: '#9ca3af'; // pending / autre
return `
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:10px 12px;font-size:14px;">${nomHtml}</td>
<td style="padding:10px 12px;font-size:13px;color:#6b7280;">${escHtml(echelle)}</td>
<td style="padding:10px 12px;">
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;
font-weight:600;background:#f3f4f6;color:${statutColor};">
${escHtml(statut)}
</span>
</td>
<td style="padding:10px 12px;font-size:12px;">
<a href="${ficheUrl}" style="color:#6b7280;">fiche #${f.Id}</a>
</td>
</tr>`;
}).join('');
return `<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background:#1a56db;padding:24px 32px;">
<p style="margin:0;font-size:12px;color:#bfdbfe;text-transform:uppercase;letter-spacing:1px;">AEP — Agences En Pratique</p>
<h1 style="margin:4px 0 0;font-size:20px;color:#ffffff;font-weight:700;">
${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}
</h1>
<p style="margin:4px 0 0;font-size:13px;color:#bfdbfe;">${date}</p>
</td>
</tr>
<!-- Corps -->
<tr>
<td style="padding:24px 32px;">
<p style="margin:0 0 16px;font-size:14px;color:#374151;">
${count} fiche${count > 1 ? 's ont été soumises' : ' a été soumise'} dans les dernières 24 heures.
</p>
<!-- Tableau fiches -->
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
<thead>
<tr style="background:#f9fafb;">
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Organisation</th>
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Échelle</th>
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Statut</th>
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Lien</th>
</tr>
</thead>
<tbody>
${lignes}
</tbody>
</table>
<!-- CTA modération -->
<div style="margin-top:24px;text-align:center;">
<a href="${NOCODB_ADMIN_URL}"
style="display:inline-block;padding:10px 20px;background:#1a56db;color:#ffffff;
border-radius:6px;font-size:14px;font-weight:600;text-decoration:none;">
Ouvrir NocoDB pour modérer
</a>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:16px 32px;border-top:1px solid #f3f4f6;">
<p style="margin:0;font-size:11px;color:#9ca3af;text-align:center;">
Digest automatique AEP — envoyé chaque matin à 8h
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// Échappement HTML minimal
function escHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ─── ENVOI VIA RESEND ─────────────────────────────────────────────────────────
async function sendDigest(fiches) {
const count = fiches.length;
const subject = `AEP — ${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}`;
const html = buildEmailHtml(fiches);
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: RESEND_FROM,
to: [EMAIL_DEST],
subject,
html
})
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Resend API ${res.status}: ${err}`);
}
const data = await res.json();
log(`Email digest envoyé → ${EMAIL_DEST} | id: ${data.id} | sujet: "${subject}"`);
}
// ─── MAIN ─────────────────────────────────────────────────────────────────────
async function run() {
log('=== daily-digest démarré ===');
// Vérification variables obligatoires
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS) {
log('ERREUR : Variables NocoDB manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS)');
process.exit(1);
}
if (!RESEND_API_KEY) {
log('ERREUR : RESEND_API_KEY manquante');
process.exit(1);
}
try {
// Fetch fiches des 24 dernières heures
const fiches = await fetchNewFiches();
log(`${fiches.length} fiche(s) trouvée(s) dans les dernières 24h`);
if (fiches.length === 0) {
log('Aucune nouvelle fiche — digest non envoyé.');
process.exit(0);
}
// Envoi email
await sendDigest(fiches);
log('=== daily-digest terminé avec succès ===');
} catch (e) {
log('ERREUR daily-digest:', e.message);
console.error(e.stack);
process.exit(1);
}
}
run();