'use strict' /** * Événements clients + inscription (RSVP). * * - Page PUBLIQUE tokenisable : GET /rsvp/[?t=&lang=fr|en] * Champs : nom, courriel, téléphone, n° client (optionnel), combien, allergies. * ?t= → pré-remplit nom/courriel + rattache d'office. * - POST /rsvp//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//rsvps (+ décompte + confiance), * DELETE /events//rsvps/, POST /events//send (mode test). * * 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, lookupCustomersByEmail, lookupCustomersByPhone } = require('./helpers') const { generateCustomerToken, verifyJwt } = require('./magic-link') const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events' try { fs.mkdirSync(DATA_DIR, { recursive: true }) } catch { /* best-effort */ } const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca' const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png' const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) 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) ─────────────────────────── const SEED = { 'fete-20-ans': { id: 'fete-20-ans', active: true, date_iso: '2026-08-01', fr: { name: 'Fête Targo 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', 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: [ { icon: '🚚', label: 'Food truck', sub: 'repas inclus' }, { icon: '🎧', label: 'DJ' }, { icon: '🎪', label: 'Jeux gonflables' }, { icon: '🎨', label: 'Maquillage' }, { 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.", closing: "Nous avons hâte de vous retrouver pour célébrer ensemble !", notes: [ 'Si vous avez des allergies, le menu pourrait ne pas vous convenir.', "Vous recevrez un coupon repas sur place (à l'accueil).", ], 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_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_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.", foot: 'TARGO — votre fournisseur Internet local', }, en: { name: "Targo's 20th Anniversary", 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: [ { icon: '🚚', label: 'Food truck', sub: 'meal included' }, { icon: '🎧', label: 'DJ' }, { icon: '🎪', label: 'Bouncy castles' }, { icon: '🎨', label: 'Face painting' }, { 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.', closing: "We can't wait to celebrate together!", notes: [ 'If you have allergies, the menu may not suit you.', 'You will receive a meal coupon on site (at the welcome desk).', ], 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_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_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.', foot: 'TARGO — your local Internet provider', }, }, } function getEvent (eventId) { return SEED[eventId] || null } function listEvents () { return Object.values(SEED).map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name })) } // ── Stockage des réponses (une map par événement, clé = client) ──────────── function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) } function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} } 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) } // ── 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 `` + `` + `
` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `
 
TARGO

${esc(t.name)}

` + `

${esc(t.tagline)}

${esc(t.greet(firstName(name)) || '')}

` + `

${esc(t.body)}

📅 ${esc(t.when)}

${prog}

⏳ ${esc(t.limited)}

${esc(t.submit)} →

${esc(t.closing)}

${esc(t.notes[0])}
${esc(t.notes[1])}

${esc(t.foot)}
` } // ── 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 `${inputHtml}${errId ? `
${esc(errMsg)}
` : ''}` } function page (event, lang, prefill = {}) { lang = normLang(lang) const t = event[lang] const other = lang === 'fr' ? 'en' : 'fr' const known = !!prefill.customer_id const greet = t.greet(firstName(prefill.name)) const program = t.program.map(p => `
${p.icon}
${esc(p.label)}${p.sub ? `${esc(p.sub)}` : ''}
` ).join('') const notes = t.notes.map((n, i) => `
${'*'.repeat(i + 1)} ${esc(n)}
`).join('') const optlbl = (s) => `${esc(s)} ${esc(t.opt)}` return ` ${esc(t.name)}
20ANS

${esc(t.name)}

${esc(t.tagline)}
📅${esc(t.when)}

${esc(t.body)}

${program}

${esc(t.program_line)}

${esc(t.limited)}

${greet ? `
${esc(greet)}
` : ''}
${field('nm', 'name', esc(t.f_name), ``, 'e_nm', t.err_name)} ${field('em', 'email', esc(t.f_email), ``, 'e_em', t.err_email)} ${field('ph', 'phone', optlbl(t.f_phone), ``)} ${field('cn', 'customer_number', optlbl(t.f_number), ``)} ${field('ps', 'party_size', esc(t.f_party), ``, 'e_ps', t.err_party)} ${field('al', 'allergies', optlbl(t.f_allerg), ``)}
${esc(t.err_generic)}
${notes}
${esc(t.foot)} · targo.ca
` } // ── 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 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 } // ── Routage HTTP ─────────────────────────────────────────────────────────── async function handle (req, res, method, path, url) { try { // Page publique : GET /rsvp/ let mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)$/) if (mm && method === 'GET') { const event = getEvent(mm[1]) if (!event || !event.active) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('

Événement introuvable

') } const token = url.searchParams.get('t') || '' let lang = url.searchParams.get('lang') || 'fr' const prefill = { token } if (token) { const p = verifyJwt(token) if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang } } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) return res.end(page(event, lang, prefill)) } // Soumission publique : POST /rsvp//submit mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)\/submit$/) if (mm && method === 'POST') { const eventId = mm[1] const event = getEvent(eventId) if (!event || !event.active) return json(res, 404, { ok: false, error: 'event_not_found' }) 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 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: 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, phone, allergies: String(b.allergies || '').trim().slice(0, 500), lang: normLang(b.lang), 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 · ${r.confidence} [${r.signals.join(',')}]`) return json(res, 200, { ok: true, confidence: r.confidence, name: firstName(name || r.customer_name) }) } // Staff : GET /events if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() }) // Staff : GET /events//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//rsvps/ 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//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, sendTest, matchAndValidate, page, normLang }