feat(events): rattachement multi-signal + validation croisée aveugle + envoi test

Suite aux demandes : bouton d'envoi test (courriel par défaut = utilisateur), auto-
rattachement par courriel/nom/téléphone, et confirmation « êtes-vous un client ? » par
recoupement — SANS autosuggest (aucune fuite de PII).

Hub lib/events.js :
- matchAndValidate({token,number,email,phone,name}) : recherche AVEUGLE de candidats par
  courriel/téléphone/numéro (en parallèle), le nom corrobore. « confirmed » = ≥2 signaux
  concordent sur le MÊME compte ; « probable » = 1 ; « none » = aucun. Jamais bloquant, ne
  renvoie jamais de donnée client. Empêche un courriel deviné de mal rattacher.
- Formulaire RSVP : ajoute Nom (requis) + Téléphone (optionnel), n° client devient optionnel.
  Message de remerciement adaptatif (« ✓ on vous a reconnu » si confirmed).
- POST /events/:id/send {test:true, test_emails[]} : envoi TEST via Mailjet/Gmail (réutilise
  email.sendEmail/gmail.sendMessage + inviteEmail + rsvpLink). Envoi de masse reste gaté (400).

Ops EventsPage : badge de confiance (Confirmé/Probable/À vérifier + signaux en infobulle),
dialogue d'envoi test (canal + courriel défaut = usePermissions().userEmail), CSV enrichi
(téléphone + confiance). Filtres par niveau de confiance.

Vérifié : 21/21 tests unitaires hub (cross-validation + envoi test mailjet/gmail + masse gatée) ;
page Ops rend sans erreur, dialogue s'ouvre avec courriel pré-rempli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 11:34:51 -04:00
parent e4a8990711
commit 50501aca5f
2 changed files with 247 additions and 141 deletions

View File

@ -81,10 +81,12 @@
<span v-else class="text-grey-5"></span>
</q-td>
</template>
<template #body-cell-matched="props">
<template #body-cell-confidence="props">
<q-td :props="props" class="text-center">
<q-icon v-if="props.value" name="verified" color="green-7" size="20px"><q-tooltip>Client rattaché</q-tooltip></q-icon>
<q-icon v-else name="help_outline" color="orange-8" size="20px"><q-tooltip>N° client non reconnu à vérifier</q-tooltip></q-icon>
<q-badge v-if="props.value === 'confirmed'" color="green-7" label="Confirmé"><q-tooltip>2 infos concordent : {{ (props.row.signals || []).join(', ') }}</q-tooltip></q-badge>
<q-badge v-else-if="props.value === 'probable'" color="blue-6" label="Probable"><q-tooltip>1 info concorde : {{ (props.row.signals || []).join(', ') }} à confirmer</q-tooltip></q-badge>
<q-badge v-else color="orange-7" label="À vérifier"><q-tooltip>Aucune concordance valider manuellement</q-tooltip></q-badge>
<div v-if="props.row.account_name && props.row.account_name !== props.row.customer_name" class="text-caption text-grey-6 q-mt-xs">dossier : {{ props.row.account_name }}</div>
</q-td>
</template>
<template #body-cell-updated="props">
@ -99,6 +101,25 @@
</div>
</div>
</div>
<!-- Dialogue d'envoi (test) -->
<q-dialog v-model="showSend">
<q-card style="min-width:320px;max-width:440px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Envoyer une invitation</div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-caption text-grey-7">Envoi d'un <b>test</b> à votre adresse (le courriel contient un lien personnalisé). L'envoi de masse (choix de l'audience) reste à configurer — rien n'est envoyé aux clients ici.</div>
<q-btn-toggle v-model="sendChannel" spread no-caps dense class="q-mt-sm"
:options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
<q-input v-model="testEmail" dense outlined type="email" label="Courriel de test" class="q-mt-sm" autofocus />
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Fermer" v-close-popup />
<q-btn unelevated no-caps color="primary" icon="send" label="Envoyer un test" :loading="sending" @click="sendTestNow" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
@ -111,7 +132,8 @@ import DataTable from 'src/components/shared/DataTable.vue'
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
import { useCsvExport } from 'src/composables/useCsvExport'
import { listRsvps, deleteRsvp } from 'src/api/events'
import { usePermissions } from 'src/composables/usePermissions'
import { listRsvps, deleteRsvp, sendInvite } from 'src/api/events'
const $q = useQuasar()
const { exportCsv } = useCsvExport()
@ -126,17 +148,23 @@ const headcount = ref(0)
const matched = ref(0)
const unmatched = ref(0)
const { userEmail } = usePermissions()
const showSend = ref(false)
const sendChannel = ref('mailjet')
const testEmail = ref('')
const sending = ref(false)
const publicUrl = computed(() => ev.value.public_url || '')
const filterState = ref({ search: '', chips: {} })
const chipGroups = [{ key: 'statut', label: 'Statut', icon: 'label', options: [{ value: 'matched', label: 'Rattachés', color: 'green-7' }, { value: 'unmatched', label: 'À vérifier', color: 'orange-8' }] }]
const chipGroups = [{ key: 'statut', label: 'Compte', icon: 'label', options: [{ value: 'confirmed', label: 'Confirmés', color: 'green-7' }, { value: 'probable', label: 'Probables', color: 'blue-6' }, { value: 'unverified', label: 'À vérifier', color: 'orange-7' }] }]
const columns = [
{ name: 'client', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'party', label: 'Personnes', field: 'party_size', align: 'center', sortable: true },
{ name: 'email', label: 'Courriel', field: 'email', align: 'left' },
{ name: 'allergies', label: 'Allergies', field: 'allergies', align: 'left' },
{ name: 'matched', label: 'Rattaché', field: 'matched', align: 'center', sortable: true },
{ name: 'confidence', label: 'Compte', field: 'confidence', align: 'center', sortable: true },
{ name: 'updated', label: 'Répondu le', field: 'updated', align: 'left', sortable: true },
{ name: 'actions', label: '', field: 'key', align: 'center' },
]
@ -145,9 +173,11 @@ const filtered = computed(() => {
const st = filterState.value.chips.statut
const q = (filterState.value.search || '').toLowerCase().trim()
return rows.value.filter(r => {
if (st === 'matched' && !r.matched) return false
if (st === 'unmatched' && r.matched) return false
if (q && !((r.customer_name || '') + ' ' + (r.customer_number || '') + ' ' + (r.email || '')).toLowerCase().includes(q)) return false
const conf = r.confidence || 'none'
if (st === 'confirmed' && conf !== 'confirmed') return false
if (st === 'probable' && conf !== 'probable') return false
if (st === 'unverified' && conf !== 'none') return false
if (q && !((r.customer_name || '') + ' ' + (r.customer_number || '') + ' ' + (r.email || '') + ' ' + (r.phone || '')).toLowerCase().includes(q)) return false
return true
})
})
@ -174,9 +204,10 @@ function copy (txt) {
}
function exportRows () {
const headers = ['Client', 'N° client', 'Personnes', 'Courriel', 'Allergies', 'Rattaché', 'Langue', 'Répondu le']
const data = filtered.value.map(r => [r.customer_name || '', r.customer_number || '', r.party_size || 0, r.email || '', r.allergies || '', r.matched ? 'Oui' : 'Non', r.lang || '', fmtDate(r.updated)])
const totalRow = ['TOTAL', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', '']
const conf = { confirmed: 'Confirmé', probable: 'Probable' }
const headers = ['Client', 'N° client', 'Téléphone', 'Personnes', 'Courriel', 'Allergies', 'Compte', 'Langue', 'Répondu le']
const data = filtered.value.map(r => [r.customer_name || '', r.customer_number || '', r.phone || '', r.party_size || 0, r.email || '', r.allergies || '', conf[r.confidence] || 'À vérifier', r.lang || '', fmtDate(r.updated)])
const totalRow = ['TOTAL', '', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', '']
exportCsv(`inscriptions_${EVENT_ID}`, headers, data, { totalRow })
}
@ -188,12 +219,16 @@ function confirmDelete (row) {
})
}
function prepSend () {
$q.dialog({
title: "Envoi d'invitations",
message: "L'envoi de courriels personnalisés (un lien pré-rempli par client) est prêt côté moteur, mais volontairement non branché : il faut d'abord choisir l'audience (tous les clients, un segment, ou un lot test). En attendant, le lien public ci-contre est déjà utilisable sur l'affiche et les réseaux sociaux.",
ok: { label: 'Compris', color: 'primary', noCaps: true },
})
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; showSend.value = true }
async function sendTestNow () {
const em = (testEmail.value || '').trim()
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
sending.value = true
try {
const r = await sendInvite(EVENT_ID, { test: true, test_emails: [em], channel: sendChannel.value })
if (r.ok) $q.notify({ type: 'positive', message: `Courriel test envoyé à ${em}`, icon: 'send' })
else $q.notify({ type: 'negative', message: 'Échec : ' + ((r.results && r.results[0] && r.results[0].error) || 'inconnu') })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { sending.value = false }
}
onMounted(load)

View File

@ -3,23 +3,24 @@
* Événements clients + inscription (RSVP).
*
* - Page PUBLIQUE tokenisable : GET /rsvp/<eventId>[?t=<jwt>&lang=fr|en]
* Le formulaire demande : n° client, combien serez-vous, courriel, allergies (optionnel).
* Avec ?t=<jwt magic-link client> n° client + nom + courriel pré-remplis et rattachés
* automatiquement ; sans jeton (affiche/réseaux sociaux) le client saisit son numéro,
* validé au mieux contre ERPNext (portal-auth.lookupCustomer).
* - POST /rsvp/<eventId>/submit enregistre la réponse (fichier, comme rating.js/campaigns),
* clé par client une re-soumission MET À JOUR (pas de doublon).
* - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte total de personnes),
* DELETE /events/<id>/rsvps/<key>.
* - Helpers d'envoi (lien perso + courriel bilingue) réutilisés par l'étape d'invitation — l'ENVOI
* lui-même est une action séparée et gatée, pas déclenchée ici.
* Champs : nom, courriel, téléphone, n° client (optionnel), combien, allergies.
* ?t=<jwt magic-link client> pré-remplit nom/courriel + rattache d'office.
* - POST /rsvp/<eventId>/submit rattachement + validation croisée AVEUGLE (voir matchAndValidate),
* clé par client re-soumission = mise à jour (pas de doublon). Jamais bloquant.
* - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte + confiance),
* DELETE /events/<id>/rsvps/<key>, POST /events/<id>/send (mode test).
*
* Réutilise : magic-link.generateCustomerToken/verifyJwt · portal-auth.lookupCustomer · helpers (json/parseBody/read|writeJsonFile/log).
* IMPORTANT (demande user) : AUCUN autosuggest/auto-remplissage à partir des données client
* (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission,
* sans jamais RENVOYER de donnée stockée. « confirmed » = 2 infos concordent sur le même compte.
*
* Réutilise : magic-link.generateCustomerToken/verifyJwt · portal-auth.lookupCustomer ·
* helpers.lookupCustomersByEmail/lookupCustomersByPhone · email.sendEmail / gmail.sendMessage.
*/
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { log, json, parseBody, readJsonFile, writeJsonFile } = require('./helpers')
const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmail, lookupCustomersByPhone } = require('./helpers')
const { generateCustomerToken, verifyJwt } = require('./magic-link')
const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events'
@ -31,8 +32,6 @@ const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr'
const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''
// ── Config événements (semé : Fête Targo 20 ans) ───────────────────────────
// La copie vit ici ; les réponses vivent dans data/events/<id>.json. Pour changer
// la date/le texte, éditer SEED (ou, plus tard, un fichier meta par événement).
const SEED = {
'fete-20-ans': {
id: 'fete-20-ans',
@ -43,13 +42,12 @@ const SEED = {
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre fête de 20 ans !",
when: '1ᵉʳ août, de 10 h à 15 h',
body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.",
program_intro: 'Au programme',
program: [
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
{ icon: '🎧', label: 'DJ' },
{ icon: '🎪', label: 'Jeux gonflables' },
{ icon: '🎨', label: 'Maquillage' },
{ icon: '🎁', label: 'Et plusieurs surprises' },
{ icon: '🎁', label: 'Et des surprises' },
],
program_line: "Le tout dans une ambiance familiale et conviviale.",
limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.",
@ -58,17 +56,22 @@ const SEED = {
'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
"Vous recevrez un coupon repas sur place (à l'accueil).",
],
f_number: 'Quel est votre numéro client ?',
f_name: 'Votre nom',
f_email: 'Votre adresse courriel',
f_phone: 'Votre téléphone',
f_number: 'Votre numéro client',
f_party: 'Combien serez-vous ?',
f_email: 'Quelle est votre adresse courriel ?',
f_allerg: 'Allergies ou restrictions alimentaires (optionnel)',
f_allerg: 'Allergies ou restrictions alimentaires',
opt: '(optionnel)',
f_party_ph: 'Nombre de personnes',
submit: 'Confirmer ma présence',
greet: (n) => n ? `Bonjour ${n} !` : '',
thanks_title: 'Merci, votre présence est confirmée ! 🎉',
thanks_body: "On a hâte de vous voir le 1ᵉʳ août. Vous recevrez votre coupon repas à l'accueil.",
thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`,
thanks_tocheck: 'Nous validerons votre inscription sous peu.',
thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
err_number: 'Veuillez entrer votre numéro client.',
err_name: 'Veuillez entrer votre nom.',
err_party: 'Veuillez indiquer combien de personnes seront présentes.',
err_email: 'Veuillez entrer une adresse courriel valide.',
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
@ -79,13 +82,12 @@ const SEED = {
tagline: 'The whole Targo team is happy to invite you to our 20th anniversary party!',
when: 'August 1st, 10 a.m. to 3 p.m.',
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
program_intro: 'On the program',
program: [
{ icon: '🚚', label: 'Food truck', sub: 'meal included' },
{ icon: '🎧', label: 'DJ' },
{ icon: '🎪', label: 'Bouncy castles' },
{ icon: '🎨', label: 'Face painting' },
{ icon: '🎁', label: 'And many surprises' },
{ icon: '🎁', label: 'And surprises' },
],
program_line: 'All in a warm, family-friendly atmosphere.',
limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.',
@ -94,17 +96,22 @@ const SEED = {
'If you have allergies, the menu may not suit you.',
'You will receive a meal coupon on site (at the welcome desk).',
],
f_number: 'What is your customer number?',
f_name: 'Your name',
f_email: 'Your email address',
f_phone: 'Your phone',
f_number: 'Your customer number',
f_party: 'How many will attend?',
f_email: 'What is your email address?',
f_allerg: 'Allergies or dietary restrictions (optional)',
f_allerg: 'Allergies or dietary restrictions',
opt: '(optional)',
f_party_ph: 'Number of people',
submit: 'Confirm my attendance',
greet: (n) => n ? `Hi ${n}!` : '',
thanks_title: "Thank you, you're confirmed! 🎉",
thanks_body: 'We look forward to seeing you on August 1st. Pick up your meal coupon at the welcome desk.',
thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`,
thanks_tocheck: "We'll confirm your registration shortly.",
thanks_update: 'You can change your answer anytime from this link.',
err_number: 'Please enter your customer number.',
err_name: 'Please enter your name.',
err_party: 'Please tell us how many people will attend.',
err_email: 'Please enter a valid email address.',
err_generic: 'Something went wrong. Please try again or contact us.',
@ -122,9 +129,100 @@ function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); ret
function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) }
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) } // <1 / non-numérique = invalide (→ 400) ; borne haute 30
function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) }
// ── Comparaison de noms (signal CORROBORANT, aveugle) ──────────────────────
function normName (s) { return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(t => t.length >= 2) }
function nameMatches (entered, candidate) {
const A = new Set(normName(entered)); const B = normName(candidate)
if (!A.size || !B.length) return false
const shared = B.filter(t => A.has(t)).length
return shared >= 2 || (shared >= 1 && (A.size <= 1 || B.length <= 1))
}
// ── Rattachement + validation croisée (AVEUGLE : ne révèle/renvoie JAMAIS de PII) ──
// Jeton = preuve forte. Sinon on cherche des candidats par courriel/téléphone/numéro (en parallèle) ;
// le NOM saisi corrobore. « confirmed » = ≥2 signaux concordent sur le MÊME compte ; « probable » = 1 ;
// « none » = aucun (accepté quand même). Aucun autosuggest, aucun retour de donnée client.
async function matchAndValidate ({ token = '', number = '', email = '', phone = '', name = '' } = {}) {
if (token) {
const p = verifyJwt(token)
if (p && p.scope === 'customer' && p.sub) return { customer_id: p.sub, customer_name: p.name || name || '', confidence: 'confirmed', signals: ['lien'] }
}
const votes = {}; const nameById = {}
const add = (cid, cname, sig) => { if (!cid) return; (votes[cid] = votes[cid] || new Set()).add(sig); if (cname && !nameById[cid]) nameById[cid] = cname }
const tasks = []
if (email) tasks.push(lookupCustomersByEmail(email, 5).then(r => (r || []).forEach(c => add(c.name, c.customer_name, 'courriel'))).catch(() => {}))
if (phone) tasks.push(lookupCustomersByPhone(phone, 5).then(r => (r || []).forEach(c => add(c.name, c.customer_name, 'téléphone'))).catch(() => {}))
if (number) tasks.push(require('./portal-auth').lookupCustomer(number).then(c => { if (c && c.name) add(c.name, c.customer_name, 'numéro') }).catch(() => {}))
await Promise.all(tasks)
if (name) for (const cid of Object.keys(votes)) if (nameMatches(name, nameById[cid])) votes[cid].add('nom')
let best = null; let bestN = 0
for (const [cid, set] of Object.entries(votes)) if (set.size > bestN) { best = cid; bestN = set.size }
if (!best) return { customer_id: null, customer_name: '', confidence: 'none', signals: [] }
const signals = [...votes[best]]
return { customer_id: best, customer_name: nameById[best] || '', confidence: signals.length >= 2 ? 'confirmed' : 'probable', signals }
}
function keyFor (resolved, form) {
if (resolved.customer_id) return 'c:' + resolved.customer_id
const id = (form.number || form.email || form.phone || '').trim().toLowerCase()
return 'x:' + id
}
// ── Lien perso + courriel d'invitation ─────────────────────────────────────
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24) {
const tok = generateCustomerToken(customerId, name, email, ttlHours)
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}`
}
function inviteSubject (eventId, lang) {
const e = getEvent(eventId); if (!e) return ''
return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
}
function inviteEmail (eventId, lang, name, rsvpUrl) {
const e = getEvent(eventId); if (!e) return ''
lang = normLang(lang); const t = e[lang]
const prog = t.program.map(p => `${p.icon} ${esc(p.label)}`).join(' &nbsp;·&nbsp; ')
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
+ `<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:36px 16px">`
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:16px;overflow:hidden;border:1px solid #e2e8f0">`
+ `<tr><td style="height:8px;background:linear-gradient(90deg,#21a34a 0 20%,#29a3dd 20% 40%,#ffcd05 40% 60%,#f7941d 60% 80%,#173a5e 80% 100%);font-size:0;line-height:0">&nbsp;</td></tr>`
+ `<tr><td style="padding:26px 30px 6px" align="center"><img src="${LOGO}" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr>`
+ `<tr><td style="padding:6px 30px 0" align="center"><h1 style="margin:8px 0 4px;font-size:24px;font-weight:800;color:#173a5e">${esc(t.name)}</h1>`
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(t.tagline)}</p></td></tr>`
+ `<tr><td style="padding:18px 30px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
+ `<tr><td style="padding:14px 30px 6px" align="center"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#173a5e;border-radius:12px;padding:12px 22px;color:#fff;font-size:17px;font-weight:700">📅 ${esc(t.when)}</td></tr></table></td></tr>`
+ `<tr><td style="padding:10px 30px 4px" align="center"><p style="margin:0;font-size:14px;color:#334155">${prog}</p></td></tr>`
+ `<tr><td style="padding:8px 30px 4px"><p style="margin:0;font-size:14px;line-height:1.6;color:#9a3412;background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;padding:11px 14px">⏳ ${esc(t.limited)}</p></td></tr>`
+ `<tr><td style="padding:20px 30px 8px" align="center"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:15px 34px;color:#fff;font-size:16px;font-weight:800;text-decoration:none">${esc(t.submit)} →</a></td></tr></table></td></tr>`
+ `<tr><td style="padding:6px 30px 22px" align="center"><p style="margin:0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>`
+ `<tr><td style="background:#f8fafc;padding:14px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">${esc(t.notes[0])}<br>${esc(t.notes[1])}<br><br>${esc(t.foot)}</td></tr>`
+ `</table></td></tr></table></body></html>`
}
// ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ──
async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) {
const results = []
for (const em of emails) {
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24)
const html = inviteEmail(eventId, lang, name, link)
const subject = '[TEST] ' + inviteSubject(eventId, lang)
try {
let ok
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined }); ok = !!r }
else ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined })
results.push({ email: em, ok: !!ok })
} catch (e) { results.push({ email: em, ok: false, error: e.message }) }
}
return results
}
// ── Page publique (festive, mobile-first, bilingue) ────────────────────────
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}`
}
function page (event, lang, prefill = {}) {
lang = normLang(lang)
const t = event[lang]
@ -135,6 +233,7 @@ function page (event, lang, prefill = {}) {
`<div class="prog"><div class="prog-ic">${p.icon}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>`
).join('')
const notes = t.notes.map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('')
const optlbl = (s) => `${esc(s)} <span class="opt">${esc(t.opt)}</span>`
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(t.name)}</title>
<style>
@ -168,6 +267,7 @@ p{line-height:1.6;font-size:1rem;margin:0 0 12px;color:#334155}
.limited p{margin:0;font-size:.92rem;color:#9a3412}
.greet{font-weight:700;color:var(--navy);font-size:1.05rem;margin:0 0 10px}
label{display:block;font-weight:600;font-size:.92rem;color:var(--navy);margin:14px 0 6px}
label .opt{font-weight:400;color:var(--muted);font-size:.82rem}
input,textarea{width:100%;border:1.5px solid var(--line);border-radius:12px;padding:13px 15px;font:inherit;font-size:1rem;background:#fcfdfc;transition:border-color .15s,box-shadow .15s}
input:focus,textarea:focus{outline:none;border-color:var(--g);box-shadow:0 0 0 4px rgba(33,163,74,.14)}
input[readonly]{background:#f1f5f9;color:var(--muted)}
@ -181,6 +281,7 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
.thanks{text-align:center;padding:14px 4px}
.thanks .big{font-size:3rem;line-height:1;margin-bottom:10px}
.thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px}
.recog{font-weight:600;color:var(--g);margin:0 0 10px}
.hidden{display:none}
</style></head>
<body><div class="confetti"></div><div class="wrap">
@ -202,22 +303,13 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
<div id="formCard" class="card">
${greet ? `<div class="greet">${esc(greet)}</div>` : ''}
<form id="rsvp" onsubmit="return submitRsvp(event)">
<label for="cn">${esc(t.f_number)}</label>
<input id="cn" name="customer_number" value="${esc(prefill.customer_number || '')}" ${known ? 'readonly' : ''} placeholder="C-12345" autocomplete="off">
<div class="err" id="e_cn">${esc(t.err_number)}</div>
<label for="ps">${esc(t.f_party)}</label>
<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">
<div class="err" id="e_ps">${esc(t.err_party)}</div>
<label for="em">${esc(t.f_email)}</label>
<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" placeholder="vous@exemple.com" autocomplete="email">
<div class="err" id="e_em">${esc(t.err_email)}</div>
<label for="al">${esc(t.f_allerg)}</label>
<textarea id="al" name="allergies" maxlength="500"></textarea>
<form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on">
${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)}
${field('em', 'email', esc(t.f_email), `<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" autocomplete="email" placeholder="vous@exemple.com">`, 'e_em', t.err_email)}
${field('ph', 'phone', optlbl(t.f_phone), `<input id="ph" name="phone" type="tel" autocomplete="tel" placeholder="819 555-1234">`)}
${field('cn', 'customer_number', optlbl(t.f_number), `<input id="cn" name="customer_number" value="${esc(prefill.customer_number || '')}" ${known ? 'readonly' : ''} autocomplete="off" placeholder="C-12345">`)}
${field('ps', 'party_size', esc(t.f_party), `<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">`, 'e_ps', t.err_party)}
${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)}
<button type="submit" id="sub">${esc(t.submit)}</button>
<div class="err" id="e_gen" style="text-align:center">${esc(t.err_generic)}</div>
</form>
@ -227,6 +319,7 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
<div class="thanks">
<div class="big">🎉</div>
<h2>${esc(t.thanks_title)}</h2>
<p class="recog" id="recog"></p>
<p>${esc(t.thanks_body)}</p>
<p style="color:var(--muted);font-size:.9rem">${esc(t.thanks_update)}</p>
</div>
@ -238,25 +331,35 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
<script>
var TOKEN=${JSON.stringify(prefill.token || '')};
var EVENT=${JSON.stringify(event.id)};
var LANG=${JSON.stringify(lang)};
var RECOG_TPL=${JSON.stringify(t.thanks_recognized('###'))};
var MSG_CHECK=${JSON.stringify(t.thanks_tocheck)};
function show(id){document.getElementById(id).classList.remove('hidden')}
function hide(id){document.getElementById(id).classList.add('hidden')}
function submitRsvp(e){
e.preventDefault();
var f=e.target;
['e_cn','e_ps','e_em','e_gen'].forEach(function(id){document.getElementById(id).style.display='none'});
var cn=(f.customer_number.value||'').trim();
var ps=parseInt(f.party_size.value,10);
['e_nm','e_em','e_ps','e_gen'].forEach(function(id){document.getElementById(id).style.display='none'});
var nm=(f.name.value||'').trim();
var em=(f.email.value||'').trim();
var ps=parseInt(f.party_size.value,10);
var ok=true;
if(!cn){document.getElementById('e_cn').style.display='block';ok=false}
if(!ps||ps<1){document.getElementById('e_ps').style.display='block';ok=false}
if(!nm){document.getElementById('e_nm').style.display='block';ok=false}
if(!em||!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(em)){document.getElementById('e_em').style.display='block';ok=false}
if(!ps||ps<1){document.getElementById('e_ps').style.display='block';ok=false}
if(!ok)return false;
var b=document.getElementById('sub');b.disabled=true;
fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({token:TOKEN,customer_number:cn,party_size:ps,email:em,allergies:(f.allergies.value||'').trim(),lang:${JSON.stringify(lang)}})})
body:JSON.stringify({token:TOKEN,name:nm,email:em,phone:(f.phone.value||'').trim(),customer_number:(f.customer_number.value||'').trim(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})})
.then(function(r){return r.json()})
.then(function(d){if(d&&d.ok){hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'})}else{b.disabled=false;document.getElementById('e_gen').style.display='block'}})
.then(function(d){
if(d&&d.ok){
var rec=document.getElementById('recog');
if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')}
else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'}
hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'})
}else{b.disabled=false;document.getElementById('e_gen').style.display='block'}
})
.catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'});
return false;
}
@ -264,71 +367,16 @@ function submitRsvp(e){
</body></html>`
}
// ── Résolution client (au mieux) : jeton d'abord, sinon numéro saisi ───────
async function resolveCustomer (token, customerNumber) {
if (token) {
const p = verifyJwt(token)
if (p && p.scope === 'customer' && p.sub) {
return { customer_id: p.sub, customer_name: p.name || '', email: p.email || '', matched: true, via: 'token' }
}
}
const raw = String(customerNumber || '').trim()
if (raw) {
try {
const { lookupCustomer } = require('./portal-auth')
const c = await lookupCustomer(raw)
if (c && c.name) return { customer_id: c.name, customer_name: c.customer_name || '', email: c.email_id || '', matched: true, via: 'form' }
} catch (e) { log('events resolveCustomer: ' + e.message) }
}
return { customer_id: null, customer_name: '', email: '', matched: false, via: 'form' }
}
function keyFor (resolved, rawNumber) {
if (resolved.customer_id) return 'c:' + resolved.customer_id
return 'n:' + String(rawNumber || '').trim().toUpperCase()
}
// ── Lien perso + courriel d'invitation (utilisés par l'étape d'envoi, gatée) ─
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24) {
const tok = generateCustomerToken(customerId, name, email, ttlHours)
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}`
}
function inviteSubject (eventId, lang) {
const e = getEvent(eventId); if (!e) return ''
return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
}
function inviteEmail (eventId, lang, name, rsvpUrl) {
const e = getEvent(eventId); if (!e) return ''
lang = normLang(lang); const t = e[lang]
const prog = t.program.map(p => `${p.icon} ${esc(p.label)}`).join(' &nbsp;·&nbsp; ')
const cta = t.submit
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
+ `<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:36px 16px">`
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:16px;overflow:hidden;border:1px solid #e2e8f0">`
+ `<tr><td style="height:8px;background:linear-gradient(90deg,#21a34a 0 20%,#29a3dd 20% 40%,#ffcd05 40% 60%,#f7941d 60% 80%,#173a5e 80% 100%);font-size:0;line-height:0">&nbsp;</td></tr>`
+ `<tr><td style="padding:26px 30px 6px" align="center"><img src="${LOGO}" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr>`
+ `<tr><td style="padding:6px 30px 0" align="center"><h1 style="margin:8px 0 4px;font-size:24px;font-weight:800;color:#173a5e">${esc(t.name)}</h1>`
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(t.tagline)}</p></td></tr>`
+ `<tr><td style="padding:18px 30px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
+ `<tr><td style="padding:14px 30px 6px" align="center"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#173a5e;border-radius:12px;padding:12px 22px;color:#fff;font-size:17px;font-weight:700">📅 ${esc(t.when)}</td></tr></table></td></tr>`
+ `<tr><td style="padding:10px 30px 4px" align="center"><p style="margin:0;font-size:14px;color:#334155">${prog}</p></td></tr>`
+ `<tr><td style="padding:8px 30px 4px"><p style="margin:0;font-size:14px;line-height:1.6;color:#9a3412;background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;padding:11px 14px">⏳ ${esc(t.limited)}</p></td></tr>`
+ `<tr><td style="padding:20px 30px 8px" align="center"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:15px 34px;color:#fff;font-size:16px;font-weight:800;text-decoration:none">${esc(cta)} →</a></td></tr></table></td></tr>`
+ `<tr><td style="padding:6px 30px 22px" align="center"><p style="margin:0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>`
+ `<tr><td style="background:#f8fafc;padding:14px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">${esc(t.notes[0])}<br>${esc(t.notes[1])}<br><br>${esc(t.foot)}</td></tr>`
+ `</table></td></tr></table></body></html>`
}
// ── Vue staff : liste + décompte ───────────────────────────────────────────
// ── Vue staff : liste + décompte + confiance ───────────────────────────────
function listRsvps (eventId) {
const m = loadResp(eventId)
const responses = Object.entries(m).map(([key, r]) => ({ key, ...r }))
.sort((a, b) => String(b.updated || b.created || '').localeCompare(String(a.updated || a.created || '')))
const headcount = responses.reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0)
const matched = responses.filter(r => r.matched).length
return { responses, count: responses.length, headcount, matched, unmatched: responses.length - matched }
const confirmed = responses.filter(r => r.confidence === 'confirmed').length
const probable = responses.filter(r => r.confidence === 'probable').length
const unverified = responses.filter(r => !r.confidence || r.confidence === 'none').length
return { responses, count: responses.length, headcount, confirmed, probable, unverified, matched: confirmed + probable, unmatched: unverified }
}
function deleteRsvp (eventId, key) { const m = loadResp(eventId); if (m[key]) { delete m[key]; saveResp(eventId, m); return true } return false }
@ -360,50 +408,73 @@ async function handle (req, res, method, path, url) {
const b = await parseBody(req)
const party = clampParty(b.party_size)
const email = String(b.email || '').trim()
const name = String(b.name || '').trim().slice(0, 120)
const phone = String(b.phone || '').trim().slice(0, 30)
const number = String(b.customer_number || '').trim().slice(0, 40)
if (!party) return json(res, 400, { ok: false, error: 'party_size' })
if (!EMAIL_RE.test(email)) return json(res, 400, { ok: false, error: 'email' })
const resolved = await resolveCustomer(b.token, b.customer_number)
const key = keyFor(resolved, b.customer_number)
const r = await matchAndValidate({ token: b.token, number, email, phone, name })
const key = keyFor(r, { number, email, phone })
const now = new Date().toISOString()
const m = loadResp(eventId)
const prev = m[key] || {}
m[key] = {
event_id: eventId,
customer_id: resolved.customer_id,
customer_number: String(b.customer_number || resolved.customer_id || '').trim(),
customer_name: resolved.customer_name || prev.customer_name || '',
customer_id: r.customer_id,
customer_number: number || (r.customer_id || ''),
customer_name: name || r.customer_name || prev.customer_name || '',
account_name: r.customer_name || '',
party_size: party,
email: email || resolved.email || prev.email || '',
email,
phone,
allergies: String(b.allergies || '').trim().slice(0, 500),
lang: normLang(b.lang),
matched: resolved.matched,
via: resolved.via,
confidence: r.confidence,
matched: r.confidence !== 'none',
signals: r.signals,
created: prev.created || now,
updated: now,
}
saveResp(eventId, m)
log(`RSVP ${eventId}${key} · ${party}p · ${resolved.matched ? 'matched' : 'unmatched'}`)
return json(res, 200, { ok: true })
log(`RSVP ${eventId}${key} · ${party}p · ${r.confidence} [${r.signals.join(',')}]`)
return json(res, 200, { ok: true, confidence: r.confidence, name: firstName(name || r.customer_name) })
}
// Staff : GET /events (liste des événements)
// Staff : GET /events
if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() })
// Staff : GET /events/<id>/rsvps · DELETE /events/<id>/rsvps/<key>
// Staff : GET /events/<id>/rsvps
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
if (mm && method === 'GET') {
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
const data = listRsvps(mm[1])
return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, public_url: `${pub()}/rsvp/${event.id}` }, ...data })
}
// Staff : DELETE /events/<id>/rsvps/<key>
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
if (mm && method === 'DELETE') {
const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2]))
return json(res, ok ? 200 : 404, { ok })
}
// Staff : POST /events/<id>/send (mode TEST uniquement ; masse = pas encore activé)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/)
if (mm && method === 'POST') {
const eventId = mm[1]; const event = getEvent(eventId)
if (!event) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
const channel = b.channel === 'gmail' ? 'gmail' : 'mailjet'
if (b.test) {
const emails = [...new Set((b.test_emails || []).map(e => String(e).trim()).filter(e => EMAIL_RE.test(e)))].slice(0, 10)
if (!emails.length) return json(res, 400, { error: 'no_test_emails' })
const results = await sendTest({ eventId, emails, channel, from: b.from, lang: b.lang || 'fr', name: b.name })
log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`)
return json(res, 200, { ok: results.every(r => r.ok), test: true, results })
}
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." })
}
return json(res, 404, { error: 'not found' })
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
}
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, page, normLang }
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, page, normLang }