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:
parent
e4a8990711
commit
50501aca5f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(' · ')
|
||||
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"> </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(' · ')
|
||||
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"> </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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user