'use strict' /** * Événements clients + inscription (RSVP). * * - Page PUBLIQUE tokenisable : GET /rsvp/[?t=&lang=fr|en] * Le formulaire demande : n° client, combien serez-vous, courriel, allergies (optionnel). * Avec ?t= → 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//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//rsvps (+ décompte total de personnes), * DELETE /events//rsvps/. * - 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). */ const fs = require('fs') const path = require('path') const cfg = require('./config') const { log, json, parseBody, readJsonFile, writeJsonFile } = 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) ─────────────────────────── // La copie vit ici ; les réponses vivent dans data/events/.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', 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_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' }, ], 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_number: 'Quel est votre numéro client ?', f_party: 'Combien serez-vous ?', f_email: 'Quelle est votre adresse courriel ?', f_allerg: 'Allergies ou restrictions alimentaires (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_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.', err_number: 'Veuillez entrer votre numéro client.', 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_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' }, ], 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_number: 'What is your customer number?', f_party: 'How many will attend?', f_email: 'What is your email address?', f_allerg: 'Allergies or dietary restrictions (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_update: 'You can change your answer anytime from this link.', err_number: 'Please enter your customer number.', 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) } // <1 / non-numérique = invalide (→ 400) ; borne haute 30 // ── Page publique (festive, mobile-first, bilingue) ──────────────────────── 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('') 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)}
` : ''}
${esc(t.err_number)}
${esc(t.err_party)}
${esc(t.err_email)}
${esc(t.err_generic)}
${notes}
${esc(t.foot)} · targo.ca
` } // ── 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 `` + `` + `
` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `
 
TARGO

${esc(t.name)}

` + `

${esc(t.tagline)}

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

` + `

${esc(t.body)}

📅 ${esc(t.when)}

${prog}

⏳ ${esc(t.limited)}

${esc(cta)} →

${esc(t.closing)}

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

${esc(t.foot)}
` } // ── Vue staff : liste + décompte ─────────────────────────────────────────── 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 } } 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() 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 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 || '', party_size: party, email: email || resolved.email || prev.email || '', allergies: String(b.allergies || '').trim().slice(0, 500), lang: normLang(b.lang), matched: resolved.matched, via: resolved.via, 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 }) } // Staff : GET /events (liste des événements) if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() }) // Staff : GET /events//rsvps · DELETE /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 }) } 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 }) } 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 }