diff --git a/apps/ops/src/api/events.js b/apps/ops/src/api/events.js new file mode 100644 index 0000000..80294f5 --- /dev/null +++ b/apps/ops/src/api/events.js @@ -0,0 +1,27 @@ +/** + * api/events.js — Client des endpoints Hub /events (vue staff RSVP). + * Miroir de services/targo-hub/lib/events.js. Passe par le proxy /ops/hub + * (jeton HUB_SERVICE_TOKEN injecté) → routes /events gatées (PII). + * + * listEvents() → { events:[{id,name,date_iso,active}] } + * listRsvps(eventId) → { event:{...,public_url}, responses:[], count, headcount, matched, unmatched } + * deleteRsvp(eventId, key) → { ok } + * sendInvite(eventId, body) → envoi d'invitations (GATÉ — non branché pour l'instant) + */ +import { HUB_URL } from 'src/config/hub' + +async function hubFetch (path, { method = 'GET', body } = {}) { + const opts = { method, headers: { 'Content-Type': 'application/json' } } + if (body) opts.body = JSON.stringify(body) + const res = await fetch(`${HUB_URL}${path}`, opts) + const text = await res.text() + let data + try { data = text ? JSON.parse(text) : {} } catch { throw new Error(`Réponse invalide de ${path}`) } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + return data +} + +export async function listEvents () { return hubFetch('/events') } +export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps`) } +export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) } +export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) } diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js index ecd31e5..a0a350c 100644 --- a/apps/ops/src/config/nav.js +++ b/apps/ops/src/config/nav.js @@ -27,6 +27,7 @@ export const navItems = [ { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' }, { path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', section: 'admin' }, { path: '/campaigns/pool', icon: 'Award', label: 'Récompenses', requires: 'manage_users', section: 'admin' }, + { path: '/evenements', icon: 'PartyPopper', label: 'Événements', requires: 'manage_users', section: 'admin' }, { path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', section: 'admin' }, { path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', section: 'admin' }, { path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', section: 'admin' }, diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index 44323ce..b336e7a 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -178,7 +178,7 @@ import { navItems as allNavItems, navSections } from 'src/config/nav' import { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, - CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, + CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper, } from 'lucide-vue-next' // Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir // le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref → sûr. NotificationBell reste synchrone (barre du haut, toujours visible). @@ -193,7 +193,7 @@ const OrchestratorDialog = defineAsyncComponent(() => import('src/components/sha const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue')) const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue')) -const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat } +const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper } const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations() const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone() diff --git a/apps/ops/src/pages/EventsPage.vue b/apps/ops/src/pages/EventsPage.vue new file mode 100644 index 0000000..59f45d3 --- /dev/null +++ b/apps/ops/src/pages/EventsPage.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js index f36d5bb..0a02ad2 100644 --- a/apps/ops/src/router/index.js +++ b/apps/ops/src/router/index.js @@ -57,6 +57,7 @@ const routes = [ { path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') }, { path: 'taches-pilote', component: () => import('src/pages/TaskGraphPage.vue') }, { path: 'network', component: () => import('src/pages/NetworkPage.vue') }, + { path: 'evenements', component: () => import('src/pages/EventsPage.vue') }, { path: 'sync-legacy', component: () => import('src/pages/LegacySyncPage.vue') }, // Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates { path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') }, diff --git a/services/targo-hub/lib/events.js b/services/targo-hub/lib/events.js new file mode 100644 index 0000000..012f0ca --- /dev/null +++ b/services/targo-hub/lib/events.js @@ -0,0 +1,409 @@ +'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 } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index e6b7859..14eb272 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -89,6 +89,7 @@ const server = http.createServer(async (req, res) => { const PUBLIC = [ '/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store', '/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/', '/rate', + '/rsvp/', // inscription événement (RSVP) : page + soumission publiques ; ?t= pré-remplit. La vue staff /events reste gatée (ALWAYS_ENFORCE). '/acte/form', '/acte/data', '/acte/submit', '/acte/photo', '/acte/entente/doc', // page sous-traitant payé à l'acte (token vérifié dans le handler) — /acte/send /acte/rates /acte/list /acte/img /acte/entente (édition) restent gatés '/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/after-dial', '/voice/twiml', '/voice/status', '/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp', @@ -122,7 +123,7 @@ const server = http.createServer(async (req, res) => { '/devices', '/email-queue', '/campaigns', '/giftbit', '/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab', '/network', '/sync', '/legacy-payments', '/telephony', '/flow', - '/conversations', '/dispatch', '/sla', + '/conversations', '/dispatch', '/sla', '/events', // /events/* = vue staff RSVP (PII : noms/courriels/décompte) → token requis '/payments/charge', '/payments/refund', '/payments/ppa-run', '/payments/send-link', '/payments/record-invoice', // enregistrement manuel d'un paiement contre facture (crée un Payment Entry) → staff only ] @@ -286,6 +287,7 @@ const server = http.createServer(async (req, res) => { // 302 to the underlying Giftbit shortlink (subject to our expiry/revoke). if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path) if (path.startsWith('/rate')) return require('./lib/rating').handle(req, res, method, path, url) + if (path.startsWith('/rsvp') || path.startsWith('/events')) return require('./lib/events').handle(req, res, method, path, url) if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path) if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url) if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)