diff --git a/cloudflare-worker/DEPLOIEMENT.md b/cloudflare-worker/DEPLOIEMENT.md new file mode 100644 index 0000000..7890168 --- /dev/null +++ b/cloudflare-worker/DEPLOIEMENT.md @@ -0,0 +1,185 @@ +# Déploiement Cloudflare Worker — Welcome cron MVA + +Ce Worker fait deux choses : +1. **Proxy HubSpot** (déjà fonctionnel — vérification doublon + numéro de référence) +2. **Cron Welcome** (NOUVEAU — envoie l'email de bienvenue **après** que le client a cliqué sur "Confirmer" dans le mail HubSpot de double opt-in) + +L'email de bienvenue contient le **numéro de référence client** ET (à ajouter dans le template EmailJS) **l'adresse du dépôt à Paris**. Ces infos ne sont **jamais** envoyées avant validation de l'email — protection contre les bots et les expéditions de cartons vides. + +--- + +## Étapes de déploiement (10 min) + +### 1. Mettre à jour le code du Worker + +1. Aller sur https://dash.cloudflare.com/ +2. Workers & Pages → cliquer sur **`mva-hubspot-proxy`** +3. Onglet **Modifier le code** (ou *Quick edit*) +4. Tout sélectionner et remplacer par le contenu de `cloudflare-worker/hubspot-proxy.js` (de ce repo) +5. Cliquer **Déployer / Save and deploy** + +### 2. Variables d'environnement + +Dans **Paramètres → Variables et secrets**, ajouter (si pas déjà là) : + +| Nom | Valeur | +|-----|--------| +| `HUBSPOT_TOKEN` | `pat-eu1-...` (existant — vérifier que le scope est read+write contacts) | +| `EMAILJS_PUBLIC_KEY` | `8KUlaQ7BDVIbkZRyP` | +| `EMAILJS_SERVICE_ID` | `service_aeamo3x` | +| `EMAILJS_TEMPLATE_ID` | `template_s1kr2et` | +| `PARIS_DEPOT_ADDRESS` | **Ton adresse exacte à Paris** (rue, code postal, ville, étage, nom à indiquer sur le carton…) | + +⚠️ **`PARIS_DEPOT_ADDRESS`** est l'info la plus sensible — c'est l'adresse que tu veux protéger. Elle ne quitte jamais Cloudflare/EmailJS et n'arrive au client que dans le mail de bienvenue, qui n'est envoyé qu'aux contacts qui ont confirmé leur email. + +> Si une variable n'est pas définie, le Worker utilise le fallback hardcodé. + +### 3. Stockage KV (idempotence — empêche de spammer le welcome) + +Dans **Paramètres → Stockage et bases de données → Bindings KV** : + +1. Cliquer **Ajouter un binding** +2. Variable name : **`WELCOME_KV`** +3. Namespace : **Créer** un nouveau namespace nommé `mva-welcome-tracker` +4. Sauvegarder + +### 4. Cron Trigger — toutes les 5 minutes + +Dans **Paramètres → Déclencheurs (Triggers) → Cron Triggers** : + +1. Cliquer **Add Cron Trigger** +2. Schedule : `*/5 * * * *` +3. Sauvegarder + +### 5a. EmailJS — mettre à jour le template pour inclure l'adresse Paris + +1. Aller sur https://dashboard.emailjs.com/admin/templates +2. Cliquer sur le template `template_s1kr2et` +3. Modifier le contenu pour inclure les variables suivantes : + - `{{firstname}}` — prénom du client + - `{{reference_client}}` — sa référence (ex : `MVA-001`) + - `{{paris_address}}` — l'adresse complète du dépôt à Paris +4. Exemple de corps d'email recommandé : + +``` +Bonjour {{firstname}}, + +Bienvenue chez MVA Global Fret ! Votre inscription est confirmée. + +══════════════════════════════════════════════ +VOTRE NUMÉRO DE RÉFÉRENCE CLIENT +{{reference_client}} +══════════════════════════════════════════════ + +Conservez précieusement ce numéro — il vous permet de suivre vos colis +et nous facilite la prise en charge. + +📦 ADRESSE DU DÉPÔT À PARIS + +{{paris_address}} + +⚠️ Important : indiquez bien votre numéro de référence +({{reference_client}}) sur chaque colis que vous nous envoyez. +Cela nous permet de vous identifier rapidement. + +Pour toute question, contactez-nous via Messenger ou par WhatsApp. + +— L'équipe MVA Global Fret ++261 38 49 737 51 +``` + +5. Sauvegarder le template + +### 5b. EmailJS — autoriser les appels serveur (CRITIQUE) + +Sans cette étape, le Worker ne pourra pas envoyer les emails (erreur 403). + +1. Aller sur https://dashboard.emailjs.com/admin/account +2. Onglet **Security** +3. Décocher **"Allow EmailJS API for non-browser applications"** + - Le terme est trompeur : décocher autorise les appels serveur + - (Cocher = bloquer les appels serveur pour anti-spam) +4. Sauvegarder + +### 6. Vérifier le scope HubSpot + +Le token HubSpot doit avoir : +- ✅ `crm.objects.contacts.read` +- ✅ `crm.objects.contacts.write` (NOUVEAU — pour mettre à jour la propriété en cas de besoin) +- ✅ `crm.lists.read` (déjà bon) + +Si tu obtiens des erreurs 403, regénère le token sur https://app-eu1.hubspot.com/private-apps/148163754/ + +--- + +## Test manuel + +Une fois tout déployé, tu peux forcer une exécution du cron sans attendre 5 min : + +```bash +curl -X POST https://mva-hubspot-proxy.mvaglobalfret.workers.dev \ + -H "Content-Type: application/json" \ + -d '{"action":"triggerWelcomeQueue"}' +``` + +Réponse attendue : +```json +{ "ok": true, "stats": { "scanned": 3, "sent": 1, "skipped": 2, "errors": 0 } } +``` + +- `scanned` : combien de contacts confirmés ont été inspectés +- `sent` : combien d'emails de bienvenue ont été envoyés cette fois-ci +- `skipped` : déjà envoyés précédemment (KV tracking) +- `errors` : envois qui ont échoué + +--- + +## Logs en production + +Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exécution cron avec les stats. + +--- + +## En cas de problème + +| Symptôme | Cause probable | Fix | +|---|---|---| +| `EmailJS 403: API access disabled` | Étape 5 pas faite | Décocher "Allow API for non-browser" sur EmailJS | +| `HubSpot 403` | Token n'a pas le scope write | Regénérer token avec scope contacts.write | +| `HubSpot 401` | Token invalide / expiré | Regénérer un Private App token | +| Le cron ne tourne pas | Cron trigger pas activé | Vérifier *Triggers → Cron Triggers* | +| KV: `env.WELCOME_KV is undefined` | Binding pas créé | Vérifier *Storage & Databases → KV bindings* | +| Aucun email envoyé même après confirmation | Le filtre HubSpot ne matche pas | Inspecter dans Logs : voir si `searchConfirmedContacts` retourne des résultats | + +--- + +## Architecture finale + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 1. User remplit le formulaire sur contact.html │ +│ ↓ │ +│ 2. form-handler.js soumet à HubSpot (Forms API) │ +│ ↓ │ +│ 3. HubSpot crée le contact AVEC reference_client │ +│ ↓ │ +│ 4. HubSpot envoie l'email de double opt-in (sujet + bouton │ +│ « Confirmer » seulement — PAS la référence ni l'adresse) │ +│ ↓ │ +│ ─────── User clique sur « Confirmer » ─────── │ +│ ↓ │ +│ 5. HubSpot met à jour hs_emailconfirmationstatus = CONFIRMED │ +│ ↓ │ +│ 6. Cron Cloudflare (toutes les 5 min) : │ +│ - cherche les contacts CONFIRMED non encore welcomed │ +│ - vérifie KV : welcomed:{email} │ +│ - envoie email via EmailJS REST API │ +│ (template contient : prénom, ref, adresse Paris) │ +│ - écrit welcomed:{email} dans KV │ +│ ↓ │ +│ 7. Le client reçoit son welcome email avec sa référence ET │ +│ l'adresse de dépôt à Paris. │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Conclusion** : il est désormais impossible pour un bot de récupérer ta référence client ou ton adresse Paris sans avoir au préalable un email valide ET cliqué sur le lien de confirmation. Anti-spam blindé. ✅ diff --git a/cloudflare-worker/hubspot-proxy.js b/cloudflare-worker/hubspot-proxy.js index b96b53f..04db6c7 100644 --- a/cloudflare-worker/hubspot-proxy.js +++ b/cloudflare-worker/hubspot-proxy.js @@ -1,146 +1,298 @@ // ============================================================ -// MVA Global Fret — Cloudflare Worker : Proxy HubSpot +// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + Welcome cron // ============================================================ -// Ce Worker contourne le CORS en faisant l'appel HubSpot -// côté serveur (Cloudflare), puis renvoie le résultat -// au navigateur avec les bons en-têtes CORS. +// Ce Worker fait deux choses : // -// DÉPLOIEMENT (gratuit, sans CLI) : -// 1. Aller sur https://dash.cloudflare.com/ → Workers & Pages -// 2. Créer un Worker → coller ce code → Enregistrer et déployer -// 3. Copier l'URL du Worker (ex: https://mva-proxy.xxx.workers.dev) -// 4. Coller cette URL dans js/form-handler.js à la constante WORKER_PROXY_URL +// 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. // -// SÉCURITÉ : -// - Stocker le token dans une variable d'environnement Cloudflare : -// Workers & Pages → Votre Worker → Paramètres → Variables et secrets -// Nom : HUBSPOT_TOKEN Valeur : pat-eu1-e3c92146-bb17-45fe-8d77-0c665fc4df3b -// - Ce token est en lecture seule (crm.objects.contacts.read uniquement) // ============================================================ +// 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) { - // En-têtes CORS autorisés - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }; - - // Réponse au preflight CORS (OPTIONS) if (request.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } - - // Seule la méthode POST est acceptée if (request.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }); } - // Lecture du token (variable d'env Cloudflare ou fallback hardcodé) - const token = (env && env.HUBSPOT_TOKEN) - ? env.HUBSPOT_TOKEN - : 'pat-eu1-e3c92146-bb17-45fe-8d77-0c665fc4df3b'; + const token = env.HUBSPOT_TOKEN || FALLBACK_TOKEN; try { const body = await request.json(); const { email, action } = body; - // ── ACTION : prochain numéro de référence séquentiel ────────────────── + // ── action: nextRef ───────────────────────────────────── if (action === 'nextRef') { - const hsResponse = await fetch( - 'https://api.hubapi.com/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 hsResponse.json(); - let maxNum = 0; - - if (data.results) { - data.results.forEach(contact => { - const ref = contact.properties?.reference_client || ''; - const match = ref.match(/^MVA-(\d+)$/); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNum) maxNum = num; - } - }); - } - - const nextNum = maxNum + 1; - const nextRef = 'MVA-' + String(nextNum).padStart(3, '0'); - - return new Response(JSON.stringify({ nextRef }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ nextRef: await getNextRef(token) }); } - // ── ACTION : vérification doublon par email (défaut) ────────────────── + // ── 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 new Response( - JSON.stringify({ error: 'Email requis' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + return jsonResponse({ error: 'Email requis' }, 400); } - // Appel HubSpot CRM (pas de CORS côté Worker) - const hsResponse = await fetch( - 'https://api.hubapi.com/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 (!hsResponse.ok) { - return new Response( - JSON.stringify({ error: `HubSpot error: ${hsResponse.status}` }), - { status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - const data = await hsResponse.json(); - - return new Response(JSON.stringify(data), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }); + const data = await searchContactByEmail(token, email); + return jsonResponse(data); } catch (err) { - return new Response( - JSON.stringify({ error: err.message }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); + 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' }, + }); +}