site-mva-global-fret/cloudflare-worker/hubspot-proxy.js
MVA Global Fret 0ef9f01fd9 Worker: cron post-confirmation pour envoyer le welcome email après opt-in
Architecture finale (Option A choisie) :

1. User submit form → contact créé en HubSpot avec reference_client
2. HubSpot envoie l'email de double opt-in (sans la ref ni l'adresse Paris)
3. User clique 'Confirmer' → HubSpot met hs_emailconfirmationstatus = CONFIRMED
4. Cron Cloudflare (toutes les 5 min) :
   - Liste les contacts CONFIRMED + créés après le cutoff
   - Filtre via Cloudflare KV (welcomed:<email>) pour idempotence
   - Envoie le welcome email via EmailJS REST API avec :
     • firstname
     • reference_client
     • paris_address (depuis env var PARIS_DEPOT_ADDRESS)
   - Marque envoyé dans KV avec TTL 1 an

Protection :
- L'adresse du dépôt Paris ne quitte JAMAIS Cloudflare/EmailJS
- Elle n'arrive au client que dans le mail de bienvenue post-opt-in
- Bots qui n'ont pas un vrai email ne peuvent pas valider → ne reçoivent rien
- Anti-spam et anti-cartons-vides blindé

Ajout d'une action 'triggerWelcomeQueue' pour debug/manual run.
Doc complète dans cloudflare-worker/DEPLOIEMENT.md (étapes 1 à 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:24:26 +02:00

299 lines
11 KiB
JavaScript

// ============================================================
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + Welcome cron
// ============================================================
// Ce Worker fait deux choses :
//
// 1) Proxy HubSpot (via fetch handler, appelé par le navigateur)
// - Vérification doublon par email
// - Génération du prochain numéro de référence séquentiel
//
// 2) Cron post-confirmation (via scheduled handler, appelé par
// Cloudflare toutes les 5 min) :
// - Cherche les contacts qui ont CONFIRMÉ leur double opt-in
// - Pour chacun, envoie l'email de bienvenue (avec sa référence)
// - Marque le contact dans Cloudflare KV pour ne pas re-envoyer
//
// L'email de bienvenue n'arrive donc QU'APRÈS que le client a cliqué
// sur "Confirmer" dans le mail HubSpot. La référence client + l'adresse
// du dépôt à Paris ne fuitent jamais avant validation.
//
// ============================================================
// DÉPLOIEMENT
// ============================================================
//
// Sur https://dash.cloudflare.com/ :
//
// 1. Workers & Pages → ton Worker mva-hubspot-proxy → Modifier le code
// → coller ce fichier → Déployer
//
// 2. Workers & Pages → ton Worker → Paramètres → Variables et secrets :
// • HUBSPOT_TOKEN = pat-eu1-... (déjà existant, lecture+écriture contacts)
// • EMAILJS_PUBLIC_KEY = 8KUlaQ7BDVIbkZRyP
// • EMAILJS_SERVICE_ID = service_aeamo3x
// • EMAILJS_TEMPLATE_ID = template_s1kr2et
//
// 3. Workers & Pages → ton Worker → Paramètres → Stockage et bases
// → Bindings KV → Ajouter :
// Variable name : WELCOME_KV
// Namespace : créer "mva-welcome-tracker"
//
// 4. Workers & Pages → ton Worker → Paramètres → Déclencheurs (Triggers)
// → Cron Triggers → Ajouter :
// */5 * * * * (toutes les 5 minutes)
//
// 5. ⚠️ EmailJS : sur https://dashboard.emailjs.com/admin/account →
// Security → décocher "Allow EmailJS API for non-browser applications"
// → la décocher (= autoriser les appels serveur). Sinon le Worker ne
// pourra pas envoyer les emails.
//
// 6. ⚠️ Le token HubSpot doit avoir le scope crm.objects.contacts.write
// en plus du read (pour mettre à jour les propriétés contact).
// → si erreur 403 sur la mise à jour KV/contact, regénérer le token
// avec ce scope sur https://app-eu1.hubspot.com/private-apps/...
//
// ============================================================
// Fallbacks utilisés uniquement si les env vars ne sont pas définies dans Cloudflare
const FALLBACK_TOKEN = 'pat-eu1-e3c92146-bb17-45fe-8d77-0c665fc4df3b';
const FALLBACK_EMAILJS_PUBLIC_KEY = '8KUlaQ7BDVIbkZRyP';
const FALLBACK_EMAILJS_SERVICE_ID = 'service_aeamo3x';
const FALLBACK_EMAILJS_TEMPLATE_ID= 'template_s1kr2et';
const HUBSPOT_API = 'https://api.hubapi.com';
const EMAILJS_API = 'https://api.emailjs.com/api/v1.0/email/send';
const corsHeaders = {
'Access-Control-Allow-Origin' : '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export default {
// -----------------------------------------------------------
// 1) Handler navigateur (POST depuis le formulaire / page de confirmation)
// -----------------------------------------------------------
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const token = env.HUBSPOT_TOKEN || FALLBACK_TOKEN;
try {
const body = await request.json();
const { email, action } = body;
// ── action: nextRef ─────────────────────────────────────
if (action === 'nextRef') {
return jsonResponse({ nextRef: await getNextRef(token) });
}
// ── action: triggerWelcome (admin/debug, optionnel) ─────
if (action === 'triggerWelcomeQueue') {
const stats = await processWelcomeQueue(env);
return jsonResponse({ ok: true, stats });
}
// ── action par défaut : vérification doublon par email ──
if (!email || typeof email !== 'string') {
return jsonResponse({ error: 'Email requis' }, 400);
}
const data = await searchContactByEmail(token, email);
return jsonResponse(data);
} catch (err) {
return jsonResponse({ error: err.message }, 500);
}
},
// -----------------------------------------------------------
// 2) Handler cron (Cloudflare scheduler, toutes les 5 min)
// -----------------------------------------------------------
async scheduled(event, env, ctx) {
ctx.waitUntil(processWelcomeQueue(env));
},
};
// =============================================================
// File d'attente : envoi du welcome aux contacts confirmés
// =============================================================
async function processWelcomeQueue(env) {
const token = env.HUBSPOT_TOKEN || FALLBACK_TOKEN;
const stats = { scanned: 0, sent: 0, skipped: 0, errors: 0 };
// Liste des contacts qui ont CONFIRMÉ leur opt-in marketing
// Filtre HubSpot : hs_emailconfirmationstatus EQ "CONFIRMED"
const confirmed = await searchConfirmedContacts(token);
for (const contact of confirmed) {
stats.scanned++;
const props = contact.properties || {};
const email = (props.email || '').toLowerCase();
if (!email) { stats.skipped++; continue; }
// Idempotence : si on a déjà envoyé, on saute
const kvKey = `welcomed:${email}`;
const already = env.WELCOME_KV ? await env.WELCOME_KV.get(kvKey) : null;
if (already) { stats.skipped++; continue; }
try {
await sendWelcomeEmail(env, {
firstname : props.firstname || '',
email : email,
reference_client : props.reference_client || '',
});
// Marquer comme envoyé dans KV (TTL 1 an pour éviter de garder
// indéfiniment des entrées si quelqu'un se désabonne et se réabonne)
if (env.WELCOME_KV) {
await env.WELCOME_KV.put(kvKey, new Date().toISOString(), {
expirationTtl: 60 * 60 * 24 * 365,
});
}
stats.sent++;
} catch (err) {
stats.errors++;
console.warn('[welcome]', email, err.message);
}
}
return stats;
}
// =============================================================
// HubSpot : recherches & lectures
// =============================================================
// Date avant laquelle les contacts CONFIRMÉS ne déclenchent PAS de welcome.
// Évite de spammer les contacts déjà existants au moment du déploiement
// du nouveau cron. Tout contact créé APRÈS cette date (et qui confirme
// son email) recevra son email de bienvenue normalement.
const WELCOME_CUTOFF_ISO = '2026-05-05T00:00:00Z';
async function searchConfirmedContacts(token) {
// On limite à 100 contacts par cron run (largement suffisant pour 1 PME)
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [
{ propertyName: 'hs_emailconfirmationstatus', operator: 'EQ', value: 'CONFIRMED' },
{ propertyName: 'reference_client', operator: 'HAS_PROPERTY' },
{ propertyName: 'createdate', operator: 'GTE', value: WELCOME_CUTOFF_ISO },
],
}],
properties: ['firstname', 'lastname', 'email', 'reference_client', 'hs_emailconfirmationstatus'],
sorts: [{ propertyName: 'lastmodifieddate', direction: 'DESCENDING' }],
limit: 100,
}),
});
if (!res.ok) {
throw new Error(`HubSpot search failed: ${res.status}`);
}
const data = await res.json();
return data.results || [];
}
async function searchContactByEmail(token, email) {
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: 'email', operator: 'EQ', value: email.toLowerCase().trim() }],
}],
properties: ['firstname', 'lastname', 'email', 'reference_client'],
}),
});
if (!res.ok) {
throw new Error(`HubSpot lookup failed: ${res.status}`);
}
return res.json();
}
async function getNextRef(token) {
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: 'reference_client', operator: 'HAS_PROPERTY', value: '' }],
}],
properties: ['reference_client'],
limit: 100,
}),
});
const data = await res.json();
let maxNum = 0;
(data.results || []).forEach(c => {
const m = (c.properties?.reference_client || '').match(/^MVA-(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
if (n > maxNum) maxNum = n;
}
});
return 'MVA-' + String(maxNum + 1).padStart(3, '0');
}
// =============================================================
// EmailJS : envoi serveur via REST API
// =============================================================
async function sendWelcomeEmail(env, params) {
const payload = {
service_id : env.EMAILJS_SERVICE_ID || FALLBACK_EMAILJS_SERVICE_ID,
template_id: env.EMAILJS_TEMPLATE_ID || FALLBACK_EMAILJS_TEMPLATE_ID,
user_id : env.EMAILJS_PUBLIC_KEY || FALLBACK_EMAILJS_PUBLIC_KEY,
template_params: {
firstname : params.firstname,
email : params.email,
reference_client : params.reference_client,
// Adresse du dépôt Paris — définie via l'env var PARIS_DEPOT_ADDRESS
// dans Cloudflare. Si non définie, on envoie un placeholder visible
// pour signaler à l'admin qu'il faut la configurer.
paris_address : env.PARIS_DEPOT_ADDRESS || '[À configurer dans Cloudflare]',
},
};
const res = await fetch(EMAILJS_API, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`EmailJS ${res.status}: ${text}`);
}
}
// =============================================================
// Helpers
// =============================================================
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}