235 lines
9.5 KiB
JavaScript
235 lines
9.5 KiB
JavaScript
#!/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();
|