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> <span v-else class="text-grey-5"></span>
</q-td> </q-td>
</template> </template>
<template #body-cell-matched="props"> <template #body-cell-confidence="props">
<q-td :props="props" class="text-center"> <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-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-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-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> </q-td>
</template> </template>
<template #body-cell-updated="props"> <template #body-cell-updated="props">
@ -99,6 +101,25 @@
</div> </div>
</div> </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> </q-page>
</template> </template>
@ -111,7 +132,8 @@ import DataTable from 'src/components/shared/DataTable.vue'
import DynamicFilter from 'src/components/shared/DynamicFilter.vue' import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters' import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
import { useCsvExport } from 'src/composables/useCsvExport' 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 $q = useQuasar()
const { exportCsv } = useCsvExport() const { exportCsv } = useCsvExport()
@ -126,17 +148,23 @@ const headcount = ref(0)
const matched = ref(0) const matched = ref(0)
const unmatched = 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 publicUrl = computed(() => ev.value.public_url || '')
const filterState = ref({ search: '', chips: {} }) 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 = [ const columns = [
{ name: 'client', label: 'Client', field: 'customer_name', align: 'left', sortable: true }, { name: 'client', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'party', label: 'Personnes', field: 'party_size', align: 'center', sortable: true }, { name: 'party', label: 'Personnes', field: 'party_size', align: 'center', sortable: true },
{ name: 'email', label: 'Courriel', field: 'email', align: 'left' }, { name: 'email', label: 'Courriel', field: 'email', align: 'left' },
{ name: 'allergies', label: 'Allergies', field: 'allergies', 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: 'updated', label: 'Répondu le', field: 'updated', align: 'left', sortable: true },
{ name: 'actions', label: '', field: 'key', align: 'center' }, { name: 'actions', label: '', field: 'key', align: 'center' },
] ]
@ -145,9 +173,11 @@ const filtered = computed(() => {
const st = filterState.value.chips.statut const st = filterState.value.chips.statut
const q = (filterState.value.search || '').toLowerCase().trim() const q = (filterState.value.search || '').toLowerCase().trim()
return rows.value.filter(r => { return rows.value.filter(r => {
if (st === 'matched' && !r.matched) return false const conf = r.confidence || 'none'
if (st === 'unmatched' && r.matched) return false if (st === 'confirmed' && conf !== 'confirmed') return false
if (q && !((r.customer_name || '') + ' ' + (r.customer_number || '') + ' ' + (r.email || '')).toLowerCase().includes(q)) 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 return true
}) })
}) })
@ -174,9 +204,10 @@ function copy (txt) {
} }
function exportRows () { function exportRows () {
const headers = ['Client', 'N° client', 'Personnes', 'Courriel', 'Allergies', 'Rattaché', 'Langue', 'Répondu le'] const conf = { confirmed: 'Confirmé', probable: 'Probable' }
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 headers = ['Client', 'N° client', 'Téléphone', 'Personnes', 'Courriel', 'Allergies', 'Compte', 'Langue', 'Répondu le']
const totalRow = ['TOTAL', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', ''] 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 }) exportCsv(`inscriptions_${EVENT_ID}`, headers, data, { totalRow })
} }
@ -188,12 +219,16 @@ function confirmDelete (row) {
}) })
} }
function prepSend () { function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; showSend.value = true }
$q.dialog({ async function sendTestNow () {
title: "Envoi d'invitations", const em = (testEmail.value || '').trim()
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.", if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
ok: { label: 'Compris', color: 'primary', noCaps: true }, 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) onMounted(load)

View File

@ -3,23 +3,24 @@
* Événements clients + inscription (RSVP). * Événements clients + inscription (RSVP).
* *
* - Page PUBLIQUE tokenisable : GET /rsvp/<eventId>[?t=<jwt>&lang=fr|en] * - Page PUBLIQUE tokenisable : GET /rsvp/<eventId>[?t=<jwt>&lang=fr|en]
* Le formulaire demande : n° client, combien serez-vous, courriel, allergies (optionnel). * Champs : nom, courriel, téléphone, n° client (optionnel), combien, allergies.
* Avec ?t=<jwt magic-link client> n° client + nom + courriel pré-remplis et rattachés * ?t=<jwt magic-link client> pré-remplit nom/courriel + rattache d'office.
* automatiquement ; sans jeton (affiche/réseaux sociaux) le client saisit son numéro, * - POST /rsvp/<eventId>/submit rattachement + validation croisée AVEUGLE (voir matchAndValidate),
* validé au mieux contre ERPNext (portal-auth.lookupCustomer). * clé par client re-soumission = mise à jour (pas de doublon). Jamais bloquant.
* - POST /rsvp/<eventId>/submit enregistre la réponse (fichier, comme rating.js/campaigns), * - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte + confiance),
* clé par client une re-soumission MET À JOUR (pas de doublon). * DELETE /events/<id>/rsvps/<key>, POST /events/<id>/send (mode test).
* - 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.
* *
* 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 fs = require('fs')
const path = require('path') const path = require('path')
const cfg = require('./config') 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 { generateCustomerToken, verifyJwt } = require('./magic-link')
const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events' 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] || '' const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''
// ── Config événements (semé : Fête Targo 20 ans) ─────────────────────────── // ── 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 = { const SEED = {
'fete-20-ans': { 'fete-20-ans': {
id: '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 !", 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', 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.", 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: [ program: [
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' }, { icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
{ icon: '🎧', label: 'DJ' }, { icon: '🎧', label: 'DJ' },
{ icon: '🎪', label: 'Jeux gonflables' }, { icon: '🎪', label: 'Jeux gonflables' },
{ icon: '🎨', label: 'Maquillage' }, { icon: '🎨', label: 'Maquillage' },
{ icon: '🎁', label: 'Et plusieurs surprises' }, { icon: '🎁', label: 'Et des surprises' },
], ],
program_line: "Le tout dans une ambiance familiale et conviviale.", 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.", 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.', 'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
"Vous recevrez un coupon repas sur place (à l'accueil).", "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_party: 'Combien serez-vous ?',
f_email: 'Quelle est votre adresse courriel ?', f_allerg: 'Allergies ou restrictions alimentaires',
f_allerg: 'Allergies ou restrictions alimentaires (optionnel)', opt: '(optionnel)',
f_party_ph: 'Nombre de personnes', f_party_ph: 'Nombre de personnes',
submit: 'Confirmer ma présence', submit: 'Confirmer ma présence',
greet: (n) => n ? `Bonjour ${n} !` : '', greet: (n) => n ? `Bonjour ${n} !` : '',
thanks_title: 'Merci, votre présence est confirmée ! 🎉', 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_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.', 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_party: 'Veuillez indiquer combien de personnes seront présentes.',
err_email: 'Veuillez entrer une adresse courriel valide.', err_email: 'Veuillez entrer une adresse courriel valide.',
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.", 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!', 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.', 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.', 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: [ program: [
{ icon: '🚚', label: 'Food truck', sub: 'meal included' }, { icon: '🚚', label: 'Food truck', sub: 'meal included' },
{ icon: '🎧', label: 'DJ' }, { icon: '🎧', label: 'DJ' },
{ icon: '🎪', label: 'Bouncy castles' }, { icon: '🎪', label: 'Bouncy castles' },
{ icon: '🎨', label: 'Face painting' }, { icon: '🎨', label: 'Face painting' },
{ icon: '🎁', label: 'And many surprises' }, { icon: '🎁', label: 'And surprises' },
], ],
program_line: 'All in a warm, family-friendly atmosphere.', 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.', 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.', 'If you have allergies, the menu may not suit you.',
'You will receive a meal coupon on site (at the welcome desk).', '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_party: 'How many will attend?',
f_email: 'What is your email address?', f_allerg: 'Allergies or dietary restrictions',
f_allerg: 'Allergies or dietary restrictions (optional)', opt: '(optional)',
f_party_ph: 'Number of people', f_party_ph: 'Number of people',
submit: 'Confirm my attendance', submit: 'Confirm my attendance',
greet: (n) => n ? `Hi ${n}!` : '', greet: (n) => n ? `Hi ${n}!` : '',
thanks_title: "Thank you, you're confirmed! 🎉", 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_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.', 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_party: 'Please tell us how many people will attend.',
err_email: 'Please enter a valid email address.', err_email: 'Please enter a valid email address.',
err_generic: 'Something went wrong. Please try again or contact us.', 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) } function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) }
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/ 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) ──────────────────────── // ── 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 = {}) { function page (event, lang, prefill = {}) {
lang = normLang(lang) lang = normLang(lang)
const t = event[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>` `<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('') ).join('')
const notes = t.notes.map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</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"> 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> <title>${esc(t.name)}</title>
<style> <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} .limited p{margin:0;font-size:.92rem;color:#9a3412}
.greet{font-weight:700;color:var(--navy);font-size:1.05rem;margin:0 0 10px} .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{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,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: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)} 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{text-align:center;padding:14px 4px}
.thanks .big{font-size:3rem;line-height:1;margin-bottom:10px} .thanks .big{font-size:3rem;line-height:1;margin-bottom:10px}
.thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 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} .hidden{display:none}
</style></head> </style></head>
<body><div class="confetti"></div><div class="wrap"> <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"> <div id="formCard" class="card">
${greet ? `<div class="greet">${esc(greet)}</div>` : ''} ${greet ? `<div class="greet">${esc(greet)}</div>` : ''}
<form id="rsvp" onsubmit="return submitRsvp(event)"> <form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on">
<label for="cn">${esc(t.f_number)}</label> ${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)}
<input id="cn" name="customer_number" value="${esc(prefill.customer_number || '')}" ${known ? 'readonly' : ''} placeholder="C-12345" autocomplete="off"> ${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)}
<div class="err" id="e_cn">${esc(t.err_number)}</div> ${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">`)}
<label for="ps">${esc(t.f_party)}</label> ${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)}
<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}"> ${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)}
<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>
<button type="submit" id="sub">${esc(t.submit)}</button> <button type="submit" id="sub">${esc(t.submit)}</button>
<div class="err" id="e_gen" style="text-align:center">${esc(t.err_generic)}</div> <div class="err" id="e_gen" style="text-align:center">${esc(t.err_generic)}</div>
</form> </form>
@ -227,6 +319,7 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
<div class="thanks"> <div class="thanks">
<div class="big">🎉</div> <div class="big">🎉</div>
<h2>${esc(t.thanks_title)}</h2> <h2>${esc(t.thanks_title)}</h2>
<p class="recog" id="recog"></p>
<p>${esc(t.thanks_body)}</p> <p>${esc(t.thanks_body)}</p>
<p style="color:var(--muted);font-size:.9rem">${esc(t.thanks_update)}</p> <p style="color:var(--muted);font-size:.9rem">${esc(t.thanks_update)}</p>
</div> </div>
@ -238,25 +331,35 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
<script> <script>
var TOKEN=${JSON.stringify(prefill.token || '')}; var TOKEN=${JSON.stringify(prefill.token || '')};
var EVENT=${JSON.stringify(event.id)}; 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 show(id){document.getElementById(id).classList.remove('hidden')}
function hide(id){document.getElementById(id).classList.add('hidden')} function hide(id){document.getElementById(id).classList.add('hidden')}
function submitRsvp(e){ function submitRsvp(e){
e.preventDefault(); e.preventDefault();
var f=e.target; var f=e.target;
['e_cn','e_ps','e_em','e_gen'].forEach(function(id){document.getElementById(id).style.display='none'}); ['e_nm','e_em','e_ps','e_gen'].forEach(function(id){document.getElementById(id).style.display='none'});
var cn=(f.customer_number.value||'').trim(); var nm=(f.name.value||'').trim();
var ps=parseInt(f.party_size.value,10);
var em=(f.email.value||'').trim(); var em=(f.email.value||'').trim();
var ps=parseInt(f.party_size.value,10);
var ok=true; var ok=true;
if(!cn){document.getElementById('e_cn').style.display='block';ok=false} if(!nm){document.getElementById('e_nm').style.display='block';ok=false}
if(!ps||ps<1){document.getElementById('e_ps').style.display='block';ok=false}
if(!em||!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(em)){document.getElementById('e_em').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; if(!ok)return false;
var b=document.getElementById('sub');b.disabled=true; var b=document.getElementById('sub');b.disabled=true;
fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'}, 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(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'}); .catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'});
return false; return false;
} }
@ -264,71 +367,16 @@ function submitRsvp(e){
</body></html>` </body></html>`
} }
// ── Résolution client (au mieux) : jeton d'abord, sinon numéro saisi ─────── // ── Vue staff : liste + décompte + confiance ───────────────────────────────
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 ───────────────────────────────────────────
function listRsvps (eventId) { function listRsvps (eventId) {
const m = loadResp(eventId) const m = loadResp(eventId)
const responses = Object.entries(m).map(([key, r]) => ({ key, ...r })) const responses = Object.entries(m).map(([key, r]) => ({ key, ...r }))
.sort((a, b) => String(b.updated || b.created || '').localeCompare(String(a.updated || a.created || ''))) .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 headcount = responses.reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0)
const matched = responses.filter(r => r.matched).length const confirmed = responses.filter(r => r.confidence === 'confirmed').length
return { responses, count: responses.length, headcount, matched, unmatched: responses.length - matched } 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 } 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 b = await parseBody(req)
const party = clampParty(b.party_size) const party = clampParty(b.party_size)
const email = String(b.email || '').trim() 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 (!party) return json(res, 400, { ok: false, error: 'party_size' })
if (!EMAIL_RE.test(email)) return json(res, 400, { ok: false, error: 'email' }) if (!EMAIL_RE.test(email)) return json(res, 400, { ok: false, error: 'email' })
const resolved = await resolveCustomer(b.token, b.customer_number) const r = await matchAndValidate({ token: b.token, number, email, phone, name })
const key = keyFor(resolved, b.customer_number) const key = keyFor(r, { number, email, phone })
const now = new Date().toISOString() const now = new Date().toISOString()
const m = loadResp(eventId) const m = loadResp(eventId)
const prev = m[key] || {} const prev = m[key] || {}
m[key] = { m[key] = {
event_id: eventId, event_id: eventId,
customer_id: resolved.customer_id, customer_id: r.customer_id,
customer_number: String(b.customer_number || resolved.customer_id || '').trim(), customer_number: number || (r.customer_id || ''),
customer_name: resolved.customer_name || prev.customer_name || '', customer_name: name || r.customer_name || prev.customer_name || '',
account_name: r.customer_name || '',
party_size: party, party_size: party,
email: email || resolved.email || prev.email || '', email,
phone,
allergies: String(b.allergies || '').trim().slice(0, 500), allergies: String(b.allergies || '').trim().slice(0, 500),
lang: normLang(b.lang), lang: normLang(b.lang),
matched: resolved.matched, confidence: r.confidence,
via: resolved.via, matched: r.confidence !== 'none',
signals: r.signals,
created: prev.created || now, created: prev.created || now,
updated: now, updated: now,
} }
saveResp(eventId, m) saveResp(eventId, m)
log(`RSVP ${eventId}${key} · ${party}p · ${resolved.matched ? 'matched' : 'unmatched'}`) log(`RSVP ${eventId}${key} · ${party}p · ${r.confidence} [${r.signals.join(',')}]`)
return json(res, 200, { ok: true }) 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() }) 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$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
if (mm && method === 'GET') { if (mm && method === 'GET') {
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' }) const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
const data = listRsvps(mm[1]) 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 }) 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\/(.+)$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
if (mm && method === 'DELETE') { if (mm && method === 'DELETE') {
const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2])) const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2]))
return json(res, ok ? 200 : 404, { ok }) 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' }) return json(res, 404, { error: 'not found' })
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) } } 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 }