feat(aep): carte AEP — push Gitea 2026-04-28
This commit is contained in:
234
worker/daily-digest.js
Normal file
234
worker/daily-digest.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
Reference in New Issue
Block a user