From f038169c858232fcfbe620f77a50b926df698cf6 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 17 Dec 2025 00:20:25 +0100 Subject: [PATCH 001/143] Update app.js --- app.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app.js b/app.js index 5ab128e4b4..7879366a9f 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,10 @@ const port = process.env.PORT || 3001; app.get("/", (req, res) => res.type('html').send(html)); +app.get("/healthz", (req, res) => { + res.status(200).json({ status: "ok" }); +}); + const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); server.keepAliveTimeout = 120 * 1000; From ab69cc9e2500d3720980ce17311d84db25d8fbc2 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 17 Dec 2025 00:24:05 +0100 Subject: [PATCH 002/143] Update app.js --- app.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app.js b/app.js index 5ab128e4b4..7879366a9f 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,10 @@ const port = process.env.PORT || 3001; app.get("/", (req, res) => res.type('html').send(html)); +app.get("/healthz", (req, res) => { + res.status(200).json({ status: "ok" }); +}); + const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); server.keepAliveTimeout = 120 * 1000; From c1e2ded151f96dbd7e81e3b6aeba1abf28a5b7c1 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 17 Dec 2025 00:38:45 +0100 Subject: [PATCH 003/143] Update app.js --- app.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 7879366a9f..e3f269fb60 100644 --- a/app.js +++ b/app.js @@ -1,14 +1,20 @@ const express = require("express"); const app = express(); + const port = process.env.PORT || 3001; -app.get("/", (req, res) => res.type('html').send(html)); +app.get("/", (req, res) => { + res.send("AI TuttiBrilli backend attivo"); +}); app.get("/healthz", (req, res) => { res.status(200).json({ status: "ok" }); }); -const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); +app.listen(port, () => { + console.log(`Server running on port ${port}`); +}); + server.keepAliveTimeout = 120 * 1000; server.headersTimeout = 120 * 1000; From 9d60a07b0ed7bda9168c81c09f4a0a5bb58c1a4c Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 17 Dec 2025 01:51:55 +0100 Subject: [PATCH 004/143] Update app.js --- app.js | 49 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 3e413c3c39..bd3c66cbb0 100644 --- a/app.js +++ b/app.js @@ -1,23 +1,60 @@ const express = require("express"); const app = express(); -const port = process.env.PORT || 3001; +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); +const port = process.env.PORT || 3000; + +/** + * ROOT + */ app.get("/", (req, res) => { - res.send("AI TuttiBrilli backend attivo"); + res.send("AI Backoffice TuttiBrilli attivo"); }); +/** + * HEALTH CHECK + */ app.get("/healthz", (req, res) => { res.status(200).json({ status: "ok" }); }); -const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); +/** + * TWILIO VOICE + */ +app.post("/twilio/voice", (req, res) => { + res.type("text/xml"); + res.send(` + + + Benvenuto da Tutti Brilli. A breve parlerai con il nostro assistente. + + + `); +}); +/** + * TWILIO SMS / WHATSAPP + */ +app.post("/twilio/sms", (req, res) => { + const message = req.body.Body || ""; -server.keepAliveTimeout = 120 * 1000; -server.headersTimeout = 120 * 1000; + res.type("text/xml"); + res.send(` + + + Ciao! Hai scritto: "${message}" + Dimmi giorno e numero di persone 🙂 + + + `); +}); + +app.listen(port, () => { + console.log(`Server running on port ${port}`); +}); -const html = ` From b5252199bddc7b2ae9191d9d3289a201ca3ffadc Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 17 Dec 2025 01:55:51 +0100 Subject: [PATCH 005/143] Update app.js --- app.js | 50 -------------------------------------------------- 1 file changed, 50 deletions(-) diff --git a/app.js b/app.js index bd3c66cbb0..cb5b528ae1 100644 --- a/app.js +++ b/app.js @@ -54,53 +54,3 @@ app.post("/twilio/sms", (req, res) => { app.listen(port, () => { console.log(`Server running on port ${port}`); }); - - - - - Hello from Render! - - - - - -
- Hello from Render! -
- - -` From dd4bdc31749d875b305f0abaea6341f137d9e559 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 17 Dec 2025 01:59:02 +0100 Subject: [PATCH 006/143] Update app.js --- app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app.js b/app.js index cb5b528ae1..80f629fc1b 100644 --- a/app.js +++ b/app.js @@ -54,3 +54,4 @@ app.post("/twilio/sms", (req, res) => { app.listen(port, () => { console.log(`Server running on port ${port}`); }); +Add Twilio endpoints From 9f9ca78471bb90776427431a88c2335490b7a5a1 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Fri, 19 Dec 2025 16:19:32 +0100 Subject: [PATCH 007/143] Update app.js --- app.js | 59 ++++++++++++++++++---------------------------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/app.js b/app.js index 80f629fc1b..77dd95a10b 100644 --- a/app.js +++ b/app.js @@ -4,54 +4,31 @@ const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); -const port = process.env.PORT || 3000; - -/** - * ROOT - */ +// Root app.get("/", (req, res) => { - res.send("AI Backoffice TuttiBrilli attivo"); + res.send("AI TuttiBrilli backend attivo"); }); -/** - * HEALTH CHECK - */ +// Health check app.get("/healthz", (req, res) => { - res.status(200).json({ status: "ok" }); -}); - -/** - * TWILIO VOICE - */ -app.post("/twilio/voice", (req, res) => { - res.type("text/xml"); - res.send(` - - - Benvenuto da Tutti Brilli. A breve parlerai con il nostro assistente. - - - `); + res.json({ status: "ok" }); }); -/** - * TWILIO SMS / WHATSAPP - */ -app.post("/twilio/sms", (req, res) => { - const message = req.body.Body || ""; - - res.type("text/xml"); - res.send(` - - - Ciao! Hai scritto: "${message}" - Dimmi giorno e numero di persone 🙂 - - - `); +// ✅ VOICE ENDPOINT PER TWILIO +app.post("/voice", (req, res) => { + res.status(200).type("text/xml").send(` + + + Ciao! Hai chiamato TuttiBrilli. + + + + Il sistema è attivo e funzionante. + +`); }); +const port = process.env.PORT || 3000; app.listen(port, () => { - console.log(`Server running on port ${port}`); + console.log("Server running on port", port); }); -Add Twilio endpoints From a412825c8de3a1b805fbd488e4430887394aebc1 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 20 Dec 2025 10:42:07 +0100 Subject: [PATCH 008/143] Update app.js --- app.js | 367 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 349 insertions(+), 18 deletions(-) diff --git a/app.js b/app.js index 77dd95a10b..a404d2be6c 100644 --- a/app.js +++ b/app.js @@ -1,34 +1,365 @@ +"use strict"; + const express = require("express"); const app = express(); +// Body parsers per Twilio (x-www-form-urlencoded) + JSON app.use(express.urlencoded({ extended: false })); app.use(express.json()); -// Root +// ENV +const PORT = process.env.PORT || 3001; +const BASE_URL = process.env.BASE_URL || ""; // es: https://ai-backoffice-tuttibrilli.onrender.com + +const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; +const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; +const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ + +// Client Twilio (solo se hai settato le env) +let twilioClient = null; +if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { + const twilio = require("twilio"); + twilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); +} + +// -------------------- +// Pagine base +// -------------------- app.get("/", (req, res) => { - res.send("AI TuttiBrilli backend attivo"); + res.status(200).send("AI TuttiBrilli backend attivo"); }); -// Health check app.get("/healthz", (req, res) => { - res.json({ status: "ok" }); + res.status(200).json({ status: "ok" }); }); -// ✅ VOICE ENDPOINT PER TWILIO +// Utile solo per test browser (Twilio usa POST) +app.get("/voice", (req, res) => { + res.status(200).send("OK (Twilio usa POST su /voice)"); +}); + +// -------------------- +// Helpers TwiML +// -------------------- +function twiml(xmlInsideResponseTag) { + // xmlInsideResponseTag deve contenere solo i tag dentro ... + return \n\n${xmlInsideResponseTag}\n; +} + +function say(text) { + // XML escape minimo + const safe = String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + return ${safe}; +} + +function gather({ action, method = "POST", input = "dtmf", numDigits, timeout = 8, finishOnKey = "#", prompt }) { + const nd = numDigits ? ` numDigits="${numDigits}"` : ""; + const fok = numDigits ? "" : ` finishOnKey="${finishOnKey}"`; // se numDigits è definito, finishOnKey non serve + return ` + + ${say(prompt)} +`; +} + +function redirect(url) { + return ${url}; +} + +function hangup() { + return ; +} + +// -------------------- +// “Sessioni” in memoria (test). Poi la spostiamo su DB/Google Calendar. +// -------------------- +const sessions = new Map(); // key: CallSid -> { step, name, date, time, people, whatsapp } + +// Format utility +function ddmmaaToHuman(ddmmaa) { + if (!ddmmaa || ddmmaa.length < 6) return ddmmaa || ""; + const dd = ddmmaa.slice(0, 2); + const mm = ddmmaa.slice(2, 4); + const aa = ddmmaa.slice(4, 6); + return ${dd}/${mm}/20${aa}; +} + +function hhmmToHuman(hhmm) { + if (!hhmm || hhmm.length < 4) return hhmm || ""; + return ${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}; +} + +function normalizeWhatsapp(digits) { + // ci aspettiamo input: 393xxxxxxxxx oppure 3xxxxxxxxx + const raw = String(digits || "").replace(/[^\d]/g, ""); + if (!raw) return ""; + if (raw.startsWith("39")) return whatsapp:+${raw}; + // se l’utente inserisce senza prefisso, assumiamo IT + if (raw.startsWith("3")) return whatsapp:+39${raw}; + // fallback: comunque + davanti + return whatsapp:+${raw}; +} + +// -------------------- +// TWILIO VOICE - START +// -------------------- app.post("/voice", (req, res) => { - res.status(200).type("text/xml").send(` - - - Ciao! Hai chiamato TuttiBrilli. - - - - Il sistema è attivo e funzionante. - -`); + const callSid = req.body.CallSid || local-${Date.now()}; + + sessions.set(callSid, { step: 1 }); + + const action = ${BASE_URL}/voice/step; + const body = twiml(` +${say("Ciao! Hai chiamato TuttiBrilli. Ti aiuto con la prenotazione.")} +${gather({ + action, + numDigits: 1, + timeout: 8, + prompt: "Premi 1 per prenotare. Premi 2 per informazioni." +})} +${say("Non ho ricevuto risposta. Riproviamo.")} +${redirect(${BASE_URL}/voice)} + `); + + res.type("text/xml").status(200).send(body); +}); + +// STEP HANDLER +app.post("/voice/step", async (req, res) => { + const callSid = req.body.CallSid; + const digits = (req.body.Digits || "").trim(); + + const session = sessions.get(callSid) || { step: 1 }; + + try { + const action = ${BASE_URL}/voice/step; + + // STEP 1: scelta + if (session.step === 1) { + if (digits === "1") { + session.step = 2; + sessions.set(callSid, session); + + const body = twiml(` +${say("Perfetto. Iniziamo.")} +${gather({ + action, + timeout: 10, + finishOnKey: "#", + prompt: + "Inserisci il tuo nome e cognome usando i tasti del telefono, poi premi cancelletto. " + + "Se non vuoi, premi subito cancelletto e andiamo avanti." +})} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + if (digits === "2") { + const body = twiml(` +${say("Per informazioni puoi scriverci su WhatsApp. Grazie!")} +${hangup()} + `); + sessions.delete(callSid); + return res.type("text/xml").status(200).send(body); + } + + const body = twiml(` +${say("Scelta non valida.")} +${redirect(${BASE_URL}/voice)} + `); + return res.type("text/xml").status(200).send(body); + } + + // STEP 2: nome (DTMF grezzo, ok per test) + if (session.step === 2) { + session.name = digits ? digits : "(nome da confermare)"; + session.step = 3; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + numDigits: 6, + timeout: 12, + prompt: + "Inserisci la data in formato G G M M A A. " + + "Esempio: 2 5 1 2 2 5 per 25 dicembre 2025." +})} +${say("Non ho ricevuto la data.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 3: data DDMMAA + if (session.step === 3) { + if (!digits || digits.length !== 6) { + const body = twiml(` +${say("Formato data non valido.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.date = digits; + session.step = 4; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + numDigits: 4, + timeout: 12, + prompt: + "Inserisci l'orario in formato O O M M. " + + "Esempio: 2 0 3 0 per 20 e 30." +})} +${say("Non ho ricevuto l'orario.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 4: ora HHMM + if (session.step === 4) { + if (!digits || digits.length !== 4) { + const body = twiml(` +${say("Formato orario non valido.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.time = digits; + session.step = 5; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + numDigits: 2, + timeout: 10, + prompt: "Quante persone? Inserisci 1 o 2 cifre." +})} +${say("Non ho ricevuto il numero di persone.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 5: persone + if (session.step === 5) { + if (!digits) { + const body = twiml(` +${say("Numero di persone non valido.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.people = digits; + session.step = 6; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + timeout: 15, + finishOnKey: "#", + prompt: + "Ora inserisci il tuo numero WhatsApp con prefisso. " + + "Esempio: 3 9 3 ... Poi premi cancelletto." +})} +${say("Non ho ricevuto il numero WhatsApp.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 6: whatsapp + invio WA + if (session.step === 6) { + const waTo = normalizeWhatsapp(digits); + + // Controlli minimi + if (!waTo || waTo.length < 14) { + const body = twiml(` +${say("Numero WhatsApp non valido. Riproviamo.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.whatsapp = waTo; + session.step = 7; + sessions.set(callSid, session); + + // Costruisci riepilogo + const humanDate = ddmmaaToHuman(session.date); + const humanTime = hhmmToHuman(session.time); + + const message = +`✅ Richiesta prenotazione ricevuta +Nome: ${session.name} +Data: ${humanDate} +Ora: ${humanTime} +Persone: ${session.people} + +Rispondi a questo WhatsApp per confermare o modificare.`; + + // Invia WhatsApp (se configurato) + if (!twilioClient) { + console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); + } else if (!TWILIO_WHATSAPP_FROM) { + console.error("Manca TWILIO_WHATSAPP_FROM (es. whatsapp:+14155238886)"); + } else { + await twilioClient.messages.create({ + from: TWILIO_WHATSAPP_FROM, + to: waTo, + body: message, + }); + } + + const body = twiml(` +${say("Perfetto. Ti ho inviato un messaggio WhatsApp con il riepilogo. Grazie!")} +${hangup()} + `); + + sessions.delete(callSid); + return res.type("text/xml").status(200).send(body); + } + + // fallback + sessions.delete(callSid); + const body = twiml(` +${say("Ripartiamo da capo.")} +${redirect(${BASE_URL}/voice)} + `); + return res.type("text/xml").status(200).send(body); + } catch (err) { + console.error("VOICE FLOW ERROR:", err); + + const body = twiml(` +${say("C'è stato un problema tecnico. Riprova tra poco.")} +${hangup()} + `); + + sessions.delete(callSid); + return res.type("text/xml").status(200).send(body); + } }); -const port = process.env.PORT || 3000; -app.listen(port, () => { - console.log("Server running on port", port); +// -------------------- +// START SERVER +// -------------------- +app.listen(PORT, () => { + console.log(Server running on port ${PORT}); + console.log(BASE_URL: ${BASE_URL || "(non impostato)"}); }); From 81ba1ed6143c726372822167b4e292d5231b33a4 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 20 Dec 2025 11:03:13 +0100 Subject: [PATCH 009/143] Update app.js --- app.js | 366 ++++----------------------------------------------------- 1 file changed, 20 insertions(+), 346 deletions(-) diff --git a/app.js b/app.js index a404d2be6c..57cc4f8acd 100644 --- a/app.js +++ b/app.js @@ -1,365 +1,39 @@ -"use strict"; - const express = require("express"); -const app = express(); -// Body parsers per Twilio (x-www-form-urlencoded) + JSON +const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); -// ENV -const PORT = process.env.PORT || 3001; -const BASE_URL = process.env.BASE_URL || ""; // es: https://ai-backoffice-tuttibrilli.onrender.com - -const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; -const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; -const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ - -// Client Twilio (solo se hai settato le env) -let twilioClient = null; -if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { - const twilio = require("twilio"); - twilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); -} - -// -------------------- -// Pagine base -// -------------------- +// Home app.get("/", (req, res) => { - res.status(200).send("AI TuttiBrilli backend attivo"); + res.status(200).send("OK - TuttiBrilli server"); }); +// Health check app.get("/healthz", (req, res) => { res.status(200).json({ status: "ok" }); }); -// Utile solo per test browser (Twilio usa POST) +// (Facoltativo) per test dal browser: ti fa vedere un messaggio se apri /voice app.get("/voice", (req, res) => { - res.status(200).send("OK (Twilio usa POST su /voice)"); + res + .status(200) + .send("Questo endpoint /voice è per Twilio e va chiamato in POST."); }); -// -------------------- -// Helpers TwiML -// -------------------- -function twiml(xmlInsideResponseTag) { - // xmlInsideResponseTag deve contenere solo i tag dentro ... - return \n\n${xmlInsideResponseTag}\n; -} - -function say(text) { - // XML escape minimo - const safe = String(text) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); - return ${safe}; -} - -function gather({ action, method = "POST", input = "dtmf", numDigits, timeout = 8, finishOnKey = "#", prompt }) { - const nd = numDigits ? ` numDigits="${numDigits}"` : ""; - const fok = numDigits ? "" : ` finishOnKey="${finishOnKey}"`; // se numDigits è definito, finishOnKey non serve - return ` - - ${say(prompt)} -`; -} - -function redirect(url) { - return ${url}; -} - -function hangup() { - return ; -} - -// -------------------- -// “Sessioni” in memoria (test). Poi la spostiamo su DB/Google Calendar. -// -------------------- -const sessions = new Map(); // key: CallSid -> { step, name, date, time, people, whatsapp } - -// Format utility -function ddmmaaToHuman(ddmmaa) { - if (!ddmmaa || ddmmaa.length < 6) return ddmmaa || ""; - const dd = ddmmaa.slice(0, 2); - const mm = ddmmaa.slice(2, 4); - const aa = ddmmaa.slice(4, 6); - return ${dd}/${mm}/20${aa}; -} - -function hhmmToHuman(hhmm) { - if (!hhmm || hhmm.length < 4) return hhmm || ""; - return ${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}; -} - -function normalizeWhatsapp(digits) { - // ci aspettiamo input: 393xxxxxxxxx oppure 3xxxxxxxxx - const raw = String(digits || "").replace(/[^\d]/g, ""); - if (!raw) return ""; - if (raw.startsWith("39")) return whatsapp:+${raw}; - // se l’utente inserisce senza prefisso, assumiamo IT - if (raw.startsWith("3")) return whatsapp:+39${raw}; - // fallback: comunque + davanti - return whatsapp:+${raw}; -} - -// -------------------- -// TWILIO VOICE - START -// -------------------- +// Endpoint Twilio VOICE (POST) app.post("/voice", (req, res) => { - const callSid = req.body.CallSid || local-${Date.now()}; - - sessions.set(callSid, { step: 1 }); - - const action = ${BASE_URL}/voice/step; - const body = twiml(` -${say("Ciao! Hai chiamato TuttiBrilli. Ti aiuto con la prenotazione.")} -${gather({ - action, - numDigits: 1, - timeout: 8, - prompt: "Premi 1 per prenotare. Premi 2 per informazioni." -})} -${say("Non ho ricevuto risposta. Riproviamo.")} -${redirect(${BASE_URL}/voice)} - `); - - res.type("text/xml").status(200).send(body); -}); - -// STEP HANDLER -app.post("/voice/step", async (req, res) => { - const callSid = req.body.CallSid; - const digits = (req.body.Digits || "").trim(); - - const session = sessions.get(callSid) || { step: 1 }; - - try { - const action = ${BASE_URL}/voice/step; - - // STEP 1: scelta - if (session.step === 1) { - if (digits === "1") { - session.step = 2; - sessions.set(callSid, session); - - const body = twiml(` -${say("Perfetto. Iniziamo.")} -${gather({ - action, - timeout: 10, - finishOnKey: "#", - prompt: - "Inserisci il tuo nome e cognome usando i tasti del telefono, poi premi cancelletto. " + - "Se non vuoi, premi subito cancelletto e andiamo avanti." -})} -${redirect(action)} - `); - - return res.type("text/xml").status(200).send(body); - } - - if (digits === "2") { - const body = twiml(` -${say("Per informazioni puoi scriverci su WhatsApp. Grazie!")} -${hangup()} - `); - sessions.delete(callSid); - return res.type("text/xml").status(200).send(body); - } - - const body = twiml(` -${say("Scelta non valida.")} -${redirect(${BASE_URL}/voice)} - `); - return res.type("text/xml").status(200).send(body); - } - - // STEP 2: nome (DTMF grezzo, ok per test) - if (session.step === 2) { - session.name = digits ? digits : "(nome da confermare)"; - session.step = 3; - sessions.set(callSid, session); - - const body = twiml(` -${gather({ - action, - numDigits: 6, - timeout: 12, - prompt: - "Inserisci la data in formato G G M M A A. " + - "Esempio: 2 5 1 2 2 5 per 25 dicembre 2025." -})} -${say("Non ho ricevuto la data.")} -${redirect(action)} - `); - - return res.type("text/xml").status(200).send(body); - } - - // STEP 3: data DDMMAA - if (session.step === 3) { - if (!digits || digits.length !== 6) { - const body = twiml(` -${say("Formato data non valido.")} -${redirect(action)} - `); - return res.type("text/xml").status(200).send(body); - } - - session.date = digits; - session.step = 4; - sessions.set(callSid, session); - - const body = twiml(` -${gather({ - action, - numDigits: 4, - timeout: 12, - prompt: - "Inserisci l'orario in formato O O M M. " + - "Esempio: 2 0 3 0 per 20 e 30." -})} -${say("Non ho ricevuto l'orario.")} -${redirect(action)} - `); - - return res.type("text/xml").status(200).send(body); - } - - // STEP 4: ora HHMM - if (session.step === 4) { - if (!digits || digits.length !== 4) { - const body = twiml(` -${say("Formato orario non valido.")} -${redirect(action)} - `); - return res.type("text/xml").status(200).send(body); - } - - session.time = digits; - session.step = 5; - sessions.set(callSid, session); - - const body = twiml(` -${gather({ - action, - numDigits: 2, - timeout: 10, - prompt: "Quante persone? Inserisci 1 o 2 cifre." -})} -${say("Non ho ricevuto il numero di persone.")} -${redirect(action)} - `); - - return res.type("text/xml").status(200).send(body); - } - - // STEP 5: persone - if (session.step === 5) { - if (!digits) { - const body = twiml(` -${say("Numero di persone non valido.")} -${redirect(action)} - `); - return res.type("text/xml").status(200).send(body); - } - - session.people = digits; - session.step = 6; - sessions.set(callSid, session); - - const body = twiml(` -${gather({ - action, - timeout: 15, - finishOnKey: "#", - prompt: - "Ora inserisci il tuo numero WhatsApp con prefisso. " + - "Esempio: 3 9 3 ... Poi premi cancelletto." -})} -${say("Non ho ricevuto il numero WhatsApp.")} -${redirect(action)} - `); - - return res.type("text/xml").status(200).send(body); - } - - // STEP 6: whatsapp + invio WA - if (session.step === 6) { - const waTo = normalizeWhatsapp(digits); - - // Controlli minimi - if (!waTo || waTo.length < 14) { - const body = twiml(` -${say("Numero WhatsApp non valido. Riproviamo.")} -${redirect(action)} - `); - return res.type("text/xml").status(200).send(body); - } - - session.whatsapp = waTo; - session.step = 7; - sessions.set(callSid, session); - - // Costruisci riepilogo - const humanDate = ddmmaaToHuman(session.date); - const humanTime = hhmmToHuman(session.time); - - const message = -`✅ Richiesta prenotazione ricevuta -Nome: ${session.name} -Data: ${humanDate} -Ora: ${humanTime} -Persone: ${session.people} - -Rispondi a questo WhatsApp per confermare o modificare.`; - - // Invia WhatsApp (se configurato) - if (!twilioClient) { - console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); - } else if (!TWILIO_WHATSAPP_FROM) { - console.error("Manca TWILIO_WHATSAPP_FROM (es. whatsapp:+14155238886)"); - } else { - await twilioClient.messages.create({ - from: TWILIO_WHATSAPP_FROM, - to: waTo, - body: message, - }); - } - - const body = twiml(` -${say("Perfetto. Ti ho inviato un messaggio WhatsApp con il riepilogo. Grazie!")} -${hangup()} - `); - - sessions.delete(callSid); - return res.type("text/xml").status(200).send(body); - } - - // fallback - sessions.delete(callSid); - const body = twiml(` -${say("Ripartiamo da capo.")} -${redirect(${BASE_URL}/voice)} - `); - return res.type("text/xml").status(200).send(body); - } catch (err) { - console.error("VOICE FLOW ERROR:", err); - - const body = twiml(` -${say("C'è stato un problema tecnico. Riprova tra poco.")} -${hangup()} - `); - - sessions.delete(callSid); - return res.type("text/xml").status(200).send(body); - } + const twiml = ` + + + Ciao! Il sistema TuttiBrilli è attivo e funzionante. + +`; + + res.type("text/xml").send(twiml); }); -// -------------------- -// START SERVER -// -------------------- -app.listen(PORT, () => { - console.log(Server running on port ${PORT}); - console.log(BASE_URL: ${BASE_URL || "(non impostato)"}); +const port = process.env.PORT || 3001; +app.listen(port, () => { + console.log(Server running on port ${port}); }); From 0cfaff99ccd3adee8e20b77b1f418b99579a7ebb Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 20 Dec 2025 11:08:54 +0100 Subject: [PATCH 010/143] Update app.js --- app.js | 115 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 19 deletions(-) diff --git a/app.js b/app.js index 57cc4f8acd..093eee6722 100644 --- a/app.js +++ b/app.js @@ -1,39 +1,116 @@ -const express = require("express"); +// app.js — TuttiBrilli backend (Render + Twilio) +// Copia e incolla TUTTO questo file in app.js +const express = require("express"); const app = express(); + +// Twilio invia i webhook come application/x-www-form-urlencoded app.use(express.urlencoded({ extended: false })); app.use(express.json()); -// Home +/* ========================= + Helpers: TwiML safe +========================= */ + +// Escape minimo per testo dentro XML (evita rotture se ci sono caratteri speciali) +function xmlEscape(input) { + return String(input) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// Wrapper TwiML: IMPORTANTISSIMO usare backtick +function twiml(inner) { + return ` + +${inner} +`; +} + +function say(text) { + return ${xmlEscape(text)}; +} + +function pause(seconds = 1) { + const s = Number(seconds); + const safe = Number.isFinite(s) && s >= 0 ? s : 1; + return ; +} + +function message(text) { + return ${xmlEscape(text)}; +} + +/* ========================= + Endpoints base (browser) +========================= */ + app.get("/", (req, res) => { - res.status(200).send("OK - TuttiBrilli server"); + // Pagina semplice per vedere che il server è su + res + .status(200) + .send("AI TuttiBrilli backend attivo ✅ (usa /healthz, /voice, /whatsapp)"); }); -// Health check app.get("/healthz", (req, res) => { res.status(200).json({ status: "ok" }); }); -// (Facoltativo) per test dal browser: ti fa vedere un messaggio se apri /voice -app.get("/voice", (req, res) => { - res - .status(200) - .send("Questo endpoint /voice è per Twilio e va chiamato in POST."); -}); +/* ========================= + Twilio VOICE (Webhook) + Configura Twilio: METHOD = POST + URL: https://ai-backoffice-tuttibrilli.onrender.com/voice +========================= */ -// Endpoint Twilio VOICE (POST) app.post("/voice", (req, res) => { - const twiml = ` - - - Ciao! Il sistema TuttiBrilli è attivo e funzionante. - -`; + // Twilio manda parametri tipo: From, To, CallSid ecc. + const from = req.body.From || "sconosciuto"; + + // Risposta TwiML + const xml = twiml(` +${say("Ciao! Hai chiamato TuttiBrilli.")} +${pause(1)} +${say("Il sistema è attivo e funzionante.")} +${pause(1)} +${say("Questo è un test. Tra poco attiveremo la presa prenotazioni.")} +${pause(1)} +${say("Numero chiamante: " + from)} + `); + + res.type("text/xml").status(200).send(xml); +}); + +/* ========================= + Twilio WhatsApp (Webhook) + Configura Twilio (Sandbox o numero WA): + METHOD = POST + URL: https://ai-backoffice-tuttibrilli.onrender.com/whatsapp +========================= */ + +app.post("/whatsapp", (req, res) => { + const incomingMsg = req.body.Body || ""; + const from = req.body.From || "sconosciuto"; + + const reply = `Ciao! ✅ Messaggio ricevuto da ${from}. +Hai scritto: "${incomingMsg}" +Questo è un test WhatsApp.`; - res.type("text/xml").send(twiml); + const xml = twiml(` +${message(reply)} + `); + + res.type("text/xml").status(200).send(xml); }); -const port = process.env.PORT || 3001; +/* ========================= + Avvio server (Render) +========================= */ + +const port = process.env.PORT || 3000; + app.listen(port, () => { console.log(Server running on port ${port}); }); From 3a837f6e6dc6fd3f66b90fdbe6e515cdea90ba9c Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 20 Dec 2025 11:17:42 +0100 Subject: [PATCH 011/143] Update app.js From 982c64f7d0b5259649dc995eaa384b0c2af09723 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 20 Dec 2025 11:20:59 +0100 Subject: [PATCH 012/143] Update app.js --- app.js | 411 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 330 insertions(+), 81 deletions(-) diff --git a/app.js b/app.js index 093eee6722..6e7f3d6f00 100644 --- a/app.js +++ b/app.js @@ -1,116 +1,365 @@ -// app.js — TuttiBrilli backend (Render + Twilio) -// Copia e incolla TUTTO questo file in app.js +"use strict"; const express = require("express"); const app = express(); -// Twilio invia i webhook come application/x-www-form-urlencoded +// Body parsers per Twilio (x-www-form-urlencoded) + JSON app.use(express.urlencoded({ extended: false })); app.use(express.json()); -/* ========================= - Helpers: TwiML safe -========================= */ - -// Escape minimo per testo dentro XML (evita rotture se ci sono caratteri speciali) -function xmlEscape(input) { - return String(input) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +// ENV +const PORT = process.env.PORT || 3001; +const BASE_URL = process.env.BASE_URL || ""; // es: https://ai-backoffice-tuttibrilli.onrender.com + +const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; +const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; +const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ + +// Client Twilio (solo se hai settato le env) +let twilioClient = null; +if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { + const twilio = require("twilio"); + twilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); } -// Wrapper TwiML: IMPORTANTISSIMO usare backtick -function twiml(inner) { - return ` - -${inner} -`; +// -------------------- +// Pagine base +// -------------------- +app.get("/", (req, res) => { + res.status(200).send("AI TuttiBrilli backend attivo"); +}); + +app.get("/healthz", (req, res) => { + res.status(200).json({ status: "ok" }); +}); + +// Utile solo per test browser (Twilio usa POST) +app.get("/voice", (req, res) => { + res.status(200).send("OK (Twilio usa POST su /voice)"); +}); + +// -------------------- +// Helpers TwiML +// -------------------- +function twiml(xmlInsideResponseTag) { + // xmlInsideResponseTag deve contenere solo i tag dentro ... + return `\n\n${xmlInsideResponseTag}\n`; } function say(text) { - return ${xmlEscape(text)}; + // XML escape minimo + const safe = String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + return `${safe}`; } -function pause(seconds = 1) { - const s = Number(seconds); - const safe = Number.isFinite(s) && s >= 0 ? s : 1; - return ; +function gather({ action, method = "POST", input = "dtmf", numDigits, timeout = 8, finishOnKey = "#", prompt }) { + const nd = numDigits ? ` numDigits="${numDigits}"` : ""; + const fok = numDigits ? "" : ` finishOnKey="${finishOnKey}"`; // se numDigits è definito, finishOnKey non serve + return ` + + ${say(prompt)} +`; } -function message(text) { - return ${xmlEscape(text)}; +function redirect(url) { + return `${url}`; } -/* ========================= - Endpoints base (browser) -========================= */ +function hangup() { + return ``; +} -app.get("/", (req, res) => { - // Pagina semplice per vedere che il server è su - res - .status(200) - .send("AI TuttiBrilli backend attivo ✅ (usa /healthz, /voice, /whatsapp)"); -}); +// -------------------- +// “Sessioni” in memoria (test). Poi la spostiamo su DB/Google Calendar. +// -------------------- +const sessions = new Map(); // key: CallSid -> { step, name, date, time, people, whatsapp } -app.get("/healthz", (req, res) => { - res.status(200).json({ status: "ok" }); -}); +// Format utility +function ddmmaaToHuman(ddmmaa) { + if (!ddmmaa || ddmmaa.length < 6) return ddmmaa || ""; + const dd = ddmmaa.slice(0, 2); + const mm = ddmmaa.slice(2, 4); + const aa = ddmmaa.slice(4, 6); + return `${dd}/${mm}/20${aa}`; +} -/* ========================= - Twilio VOICE (Webhook) - Configura Twilio: METHOD = POST - URL: https://ai-backoffice-tuttibrilli.onrender.com/voice -========================= */ +function hhmmToHuman(hhmm) { + if (!hhmm || hhmm.length < 4) return hhmm || ""; + return `${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}`; +} + +function normalizeWhatsapp(digits) { + // ci aspettiamo input: 393xxxxxxxxx oppure 3xxxxxxxxx + const raw = String(digits || "").replace(/[^\d]/g, ""); + if (!raw) return ""; + if (raw.startsWith("39")) return `whatsapp:+${raw}`; + // se l’utente inserisce senza prefisso, assumiamo IT + if (raw.startsWith("3")) return `whatsapp:+39${raw}`; + // fallback: comunque + davanti + return `whatsapp:+${raw}`; +} +// -------------------- +// TWILIO VOICE - START +// -------------------- app.post("/voice", (req, res) => { - // Twilio manda parametri tipo: From, To, CallSid ecc. - const from = req.body.From || "sconosciuto"; - - // Risposta TwiML - const xml = twiml(` -${say("Ciao! Hai chiamato TuttiBrilli.")} -${pause(1)} -${say("Il sistema è attivo e funzionante.")} -${pause(1)} -${say("Questo è un test. Tra poco attiveremo la presa prenotazioni.")} -${pause(1)} -${say("Numero chiamante: " + from)} + const callSid = req.body.CallSid || `local-${Date.now()}`; + + sessions.set(callSid, { step: 1 }); + + const action = `${BASE_URL}/voice/step`; + const body = twiml(` +${say("Ciao! Hai chiamato TuttiBrilli. Ti aiuto con la prenotazione.")} +${gather({ + action, + numDigits: 1, + timeout: 8, + prompt: "Premi 1 per prenotare. Premi 2 per informazioni." +})} +${say("Non ho ricevuto risposta. Riproviamo.")} +${redirect(`${BASE_URL}/voice`)} `); - res.type("text/xml").status(200).send(xml); + res.type("text/xml").status(200).send(body); }); -/* ========================= - Twilio WhatsApp (Webhook) - Configura Twilio (Sandbox o numero WA): - METHOD = POST - URL: https://ai-backoffice-tuttibrilli.onrender.com/whatsapp -========================= */ +// STEP HANDLER +app.post("/voice/step", async (req, res) => { + const callSid = req.body.CallSid; + const digits = (req.body.Digits || "").trim(); -app.post("/whatsapp", (req, res) => { - const incomingMsg = req.body.Body || ""; - const from = req.body.From || "sconosciuto"; + const session = sessions.get(callSid) || { step: 1 }; - const reply = `Ciao! ✅ Messaggio ricevuto da ${from}. -Hai scritto: "${incomingMsg}" -Questo è un test WhatsApp.`; + try { + const action = `${BASE_URL}/voice/step`; - const xml = twiml(` -${message(reply)} - `); + // STEP 1: scelta + if (session.step === 1) { + if (digits === "1") { + session.step = 2; + sessions.set(callSid, session); - res.type("text/xml").status(200).send(xml); -}); + const body = twiml(` +${say("Perfetto. Iniziamo.")} +${gather({ + action, + timeout: 10, + finishOnKey: "#", + prompt: + "Inserisci il tuo nome e cognome usando i tasti del telefono, poi premi cancelletto. " + + "Se non vuoi, premi subito cancelletto e andiamo avanti." +})} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + if (digits === "2") { + const body = twiml(` +${say("Per informazioni puoi scriverci su WhatsApp. Grazie!")} +${hangup()} + `); + sessions.delete(callSid); + return res.type("text/xml").status(200).send(body); + } -/* ========================= - Avvio server (Render) -========================= */ + const body = twiml(` +${say("Scelta non valida.")} +${redirect(`${BASE_URL}/voice`)} + `); + return res.type("text/xml").status(200).send(body); + } -const port = process.env.PORT || 3000; + // STEP 2: nome (DTMF grezzo, ok per test) + if (session.step === 2) { + session.name = digits ? digits : "(nome da confermare)"; + session.step = 3; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + numDigits: 6, + timeout: 12, + prompt: + "Inserisci la data in formato G G M M A A. " + + "Esempio: 2 5 1 2 2 5 per 25 dicembre 2025." +})} +${say("Non ho ricevuto la data.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 3: data DDMMAA + if (session.step === 3) { + if (!digits || digits.length !== 6) { + const body = twiml(` +${say("Formato data non valido.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.date = digits; + session.step = 4; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + numDigits: 4, + timeout: 12, + prompt: + "Inserisci l'orario in formato O O M M. " + + "Esempio: 2 0 3 0 per 20 e 30." +})} +${say("Non ho ricevuto l'orario.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 4: ora HHMM + if (session.step === 4) { + if (!digits || digits.length !== 4) { + const body = twiml(` +${say("Formato orario non valido.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.time = digits; + session.step = 5; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + numDigits: 2, + timeout: 10, + prompt: "Quante persone? Inserisci 1 o 2 cifre." +})} +${say("Non ho ricevuto il numero di persone.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 5: persone + if (session.step === 5) { + if (!digits) { + const body = twiml(` +${say("Numero di persone non valido.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.people = digits; + session.step = 6; + sessions.set(callSid, session); + + const body = twiml(` +${gather({ + action, + timeout: 15, + finishOnKey: "#", + prompt: + "Ora inserisci il tuo numero WhatsApp con prefisso. " + + "Esempio: 3 9 3 ... Poi premi cancelletto." +})} +${say("Non ho ricevuto il numero WhatsApp.")} +${redirect(action)} + `); + + return res.type("text/xml").status(200).send(body); + } + + // STEP 6: whatsapp + invio WA + if (session.step === 6) { + const waTo = normalizeWhatsapp(digits); + + // Controlli minimi + if (!waTo || waTo.length < 14) { + const body = twiml(` +${say("Numero WhatsApp non valido. Riproviamo.")} +${redirect(action)} + `); + return res.type("text/xml").status(200).send(body); + } + + session.whatsapp = waTo; + session.step = 7; + sessions.set(callSid, session); + + // Costruisci riepilogo + const humanDate = ddmmaaToHuman(session.date); + const humanTime = hhmmToHuman(session.time); + + const message = +`✅ *Richiesta prenotazione ricevuta* +Nome: ${session.name} +Data: ${humanDate} +Ora: ${humanTime} +Persone: ${session.people} + +Rispondi a questo WhatsApp per *confermare* o *modificare*.`; + + // Invia WhatsApp (se configurato) + if (!twilioClient) { + console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); + } else if (!TWILIO_WHATSAPP_FROM) { + console.error("Manca TWILIO_WHATSAPP_FROM (es. whatsapp:+14155238886)"); + } else { + await twilioClient.messages.create({ + from: TWILIO_WHATSAPP_FROM, + to: waTo, + body: message, + }); + } + + const body = twiml(` +${say("Perfetto. Ti ho inviato un messaggio WhatsApp con il riepilogo. Grazie!")} +${hangup()} + `); + + sessions.delete(callSid); + return res.type("text/xml").status(200).send(body); + } + + // fallback + sessions.delete(callSid); + const body = twiml(` +${say("Ripartiamo da capo.")} +${redirect(`${BASE_URL}/voice`)} + `); + return res.type("text/xml").status(200).send(body); + } catch (err) { + console.error("VOICE FLOW ERROR:", err); + + const body = twiml(` +${say("C'è stato un problema tecnico. Riprova tra poco.")} +${hangup()} + `); + + sessions.delete(callSid); + return res.type("text/xml").status(200).send(body); + } +}); -app.listen(port, () => { - console.log(Server running on port ${port}); +// -------------------- +// START SERVER +// -------------------- +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + console.log(`BASE_URL: ${BASE_URL || "(non impostato)"}`); }); From 5c9a3b2b0594d372fde75b363d21a1583eb171ae Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 21 Dec 2025 19:04:16 +0100 Subject: [PATCH 013/143] Update app.js From 911f866d527661b71bc1482ddaa4cbca476469b3 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 21 Dec 2025 19:07:41 +0100 Subject: [PATCH 014/143] Update app.js --- app.js | 734 ++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 545 insertions(+), 189 deletions(-) diff --git a/app.js b/app.js index 6e7f3d6f00..e942b76b33 100644 --- a/app.js +++ b/app.js @@ -13,9 +13,9 @@ const BASE_URL = process.env.BASE_URL || ""; // es: https://ai-backoffice-tuttib const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; -const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ +const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ -// Client Twilio (solo se hai settato le env) +// Client Twilio let twilioClient = null; if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { const twilio = require("twilio"); @@ -25,101 +25,321 @@ if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { // -------------------- // Pagine base // -------------------- -app.get("/", (req, res) => { - res.status(200).send("AI TuttiBrilli backend attivo"); -}); - -app.get("/healthz", (req, res) => { - res.status(200).json({ status: "ok" }); -}); - -// Utile solo per test browser (Twilio usa POST) -app.get("/voice", (req, res) => { - res.status(200).send("OK (Twilio usa POST su /voice)"); -}); +app.get("/", (req, res) => res.status(200).send("AI TuttiBrilli backend attivo")); +app.get("/healthz", (req, res) => res.status(200).json({ status: "ok" })); +app.get("/voice", (req, res) => res.status(200).send("OK (Twilio usa POST su /voice)")); // -------------------- // Helpers TwiML // -------------------- +function xmlEscape(s) { + return String(s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + function twiml(xmlInsideResponseTag) { - // xmlInsideResponseTag deve contenere solo i tag dentro ... return `\n\n${xmlInsideResponseTag}\n`; } +// Se vuoi provare voci diverse (se disponibili sul tuo account): +// - voice="alice" (standard) +// - voice="Polly.Bianca" / "Polly.Bianca-Neural" (se abilitato) function say(text) { - // XML escape minimo - const safe = String(text) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); + const safe = xmlEscape(text); return `${safe}`; } -function gather({ action, method = "POST", input = "dtmf", numDigits, timeout = 8, finishOnKey = "#", prompt }) { - const nd = numDigits ? ` numDigits="${numDigits}"` : ""; - const fok = numDigits ? "" : ` finishOnKey="${finishOnKey}"`; // se numDigits è definito, finishOnKey non serve - return ` - - ${say(prompt)} -`; +function pause(len = 1) { + return ``; } function redirect(url) { - return `${url}`; + return `${xmlEscape(url)}`; } function hangup() { return ``; } +/** + * Gather speech-only. + * - timeout: secondi di attesa prima di "no input" + * - speechTimeout: "auto" o numero (sec) di silenzio per chiudere + * - hints: parole chiave (non obbligatorio) + */ +function gatherSpeech({ + action, + method = "POST", + timeout = 6, + speechTimeout = "auto", + language = "it-IT", + prompt, + hints = "", +}) { + const safeAction = xmlEscape(action); + const safeHints = hints ? ` hints="${xmlEscape(hints)}"` : ""; + return ` + + ${say(prompt)} +`; +} + // -------------------- -// “Sessioni” in memoria (test). Poi la spostiamo su DB/Google Calendar. +// Sessioni in memoria (MVP). In produzione: Redis/DB. // -------------------- -const sessions = new Map(); // key: CallSid -> { step, name, date, time, people, whatsapp } - -// Format utility -function ddmmaaToHuman(ddmmaa) { - if (!ddmmaa || ddmmaa.length < 6) return ddmmaa || ""; - const dd = ddmmaa.slice(0, 2); - const mm = ddmmaa.slice(2, 4); - const aa = ddmmaa.slice(4, 6); - return `${dd}/${mm}/20${aa}`; +/** + * session schema: + * { + * step: number, + * retries: number, + * intent: "booking"|"info"|null, + * name: string|null, + * dateISO: "YYYY-MM-DD"|null, + * time24: "HH:MM"|null, + * people: number|null, + * waTo: "whatsapp:+39..."|null, + * fromCaller: "+39..."|null + * } + */ +const sessions = new Map(); // key: CallSid + +function getSession(callSid) { + const s = sessions.get(callSid); + if (s) return s; + const fresh = { step: 1, retries: 0, intent: null, name: null, dateISO: null, time24: null, people: null, waTo: null, fromCaller: null }; + sessions.set(callSid, fresh); + return fresh; } -function hhmmToHuman(hhmm) { - if (!hhmm || hhmm.length < 4) return hhmm || ""; - return `${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}`; +function resetRetries(session) { + session.retries = 0; } -function normalizeWhatsapp(digits) { - // ci aspettiamo input: 393xxxxxxxxx oppure 3xxxxxxxxx - const raw = String(digits || "").replace(/[^\d]/g, ""); +function incRetry(session) { + session.retries = (session.retries || 0) + 1; + return session.retries; +} + +// -------------------- +// Parsing pragmatico (MVP) +// -------------------- +function normalizeText(t) { + return String(t || "") + .trim() + .toLowerCase() + .replace(/\s+/g, " "); +} + +function looksLikeBooking(text) { + return /prenot|tavol|posti|riserv/.test(text); +} + +function looksLikeInfo(text) { + return /info|orari|indirizz|dove|menu|menù|carta|vini|evento|serata/.test(text); +} + +function nowRome() { + // Manteniamo semplice: usa timezone server. Per robustezza futura: luxon. + return new Date(); +} + +function toISODate(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function parseDateIT_MVP(speech) { + // Supporta: "oggi", "stasera" -> oggi; "domani" -> domani + // Supporta: "25/12/2025", "25-12-2025", "25/12", "2512", "25 12" + // Se manca anno -> anno corrente + const t = normalizeText(speech); + if (!t) return null; + + const now = nowRome(); + + if (/\b(oggi|stasera)\b/.test(t)) return toISODate(now); + if (/\bdomani\b/.test(t)) { + const d = new Date(now.getTime()); + d.setDate(d.getDate() + 1); + return toISODate(d); + } + + // Estrai numeri + // formati tipo 25/12/2025 o 25-12-2025 + let m = t.match(/\b(\d{1,2})[\/\-\.](\d{1,2})(?:[\/\-\.](\d{2,4}))?\b/); + if (m) { + let dd = parseInt(m[1], 10); + let mm = parseInt(m[2], 10); + let yy = m[3] ? parseInt(m[3], 10) : now.getFullYear(); + if (yy < 100) yy = 2000 + yy; + + if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { + const d = new Date(yy, mm - 1, dd); + // Validazione semplice (evita 31/02) + if (d.getFullYear() === yy && d.getMonth() === mm - 1 && d.getDate() === dd) return toISODate(d); + } + return null; + } + + // formati "2512" o "25 12" (senza anno) + const digits = t.replace(/[^\d]/g, ""); + if (digits.length === 4) { + const dd = parseInt(digits.slice(0, 2), 10); + const mm = parseInt(digits.slice(2, 4), 10); + const yy = now.getFullYear(); + if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { + const d = new Date(yy, mm - 1, dd); + if (d.getMonth() === mm - 1 && d.getDate() === dd) return toISODate(d); + } + } + + return null; +} + +function parseTimeIT_MVP(speech) { + // Supporta: "20:30", "20 e 30", "20 30", "2030", "alle 20", "ore 20" + const t = normalizeText(speech); + if (!t) return null; + + // 20:30 + let m = t.match(/\b(\d{1,2})[:\.](\d{2})\b/); + if (m) { + const hh = parseInt(m[1], 10); + const mm = parseInt(m[2], 10); + if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; + } + return null; + } + + // "20 e 30" + m = t.match(/\b(\d{1,2})\s*(?:e|e\s+le)?\s*(\d{1,2})\b/); + if (m) { + const hh = parseInt(m[1], 10); + let mm = parseInt(m[2], 10); + if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; + } + } + + // digits "2030" oppure "830" (rischioso) -> gestiamo solo 3-4 cifre + const digits = t.replace(/[^\d]/g, ""); + if (digits.length === 4) { + const hh = parseInt(digits.slice(0, 2), 10); + const mm = parseInt(digits.slice(2, 4), 10); + if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; + } + } + + // "alle 20" -> 20:00 + m = t.match(/\b(\d{1,2})\b/); + if (m) { + const hh = parseInt(m[1], 10); + if (hh >= 0 && hh <= 23) return `${String(hh).padStart(2, "0")}:00`; + } + + return null; +} + +function parsePeopleIT_MVP(speech) { + // Cerca un numero nel testo (es. "siamo in quattro" -> 4 se dice "4") + // MVP: estrae cifre. (Estendibile con mapping "due, tre, quattro") + const t = normalizeText(speech); + if (!t) return null; + + const m = t.match(/\b(\d{1,2})\b/); + if (m) { + const n = parseInt(m[1], 10); + if (n >= 1 && n <= 20) return n; + } + + // mapping minimo parole + const map = { + uno: 1, una: 1, + due: 2, + tre: 3, + quattro: 4, + cinque: 5, + sei: 6, + sette: 7, + otto: 8, + nove: 9, + dieci: 10 + }; + for (const [k, v] of Object.entries(map)) { + if (new RegExp(`\\b${k}\\b`).test(t)) return v; + } + + return null; +} + +function humanDateIT(iso) { + if (!iso) return ""; + const [y, m, d] = iso.split("-"); + return `${d}/${m}/${y}`; +} + +function normalizeWhatsappFromVoice(speechOrDigits) { + // Per voce: spesso arriva "tre nove tre ..." ma Twilio restituisce testo. + // MVP: estraiamo tutte le cifre dal testo. + const raw = String(speechOrDigits || "").replace(/[^\d]/g, ""); if (!raw) return ""; if (raw.startsWith("39")) return `whatsapp:+${raw}`; - // se l’utente inserisce senza prefisso, assumiamo IT if (raw.startsWith("3")) return `whatsapp:+39${raw}`; - // fallback: comunque + davanti + if (raw.startsWith("0")) return ""; // numeri fissi/ambigui: richiedi di ripetere con +39 return `whatsapp:+${raw}`; } +function isLikelyItalianMobileE164(e164) { + // +39 + 3xxxxxxxxx (approssimazione) + return /^\+39\d{9,12}$/.test(e164 || ""); +} + +function hasValidWaAddress(wa) { + return /^whatsapp:\+\d{8,15}$/.test(wa || ""); +} + // -------------------- // TWILIO VOICE - START // -------------------- app.post("/voice", (req, res) => { const callSid = req.body.CallSid || `local-${Date.now()}`; - - sessions.set(callSid, { step: 1 }); + const from = req.body.From || ""; // caller id (può essere il numero del forwarder) + const session = getSession(callSid); + + session.step = 1; + session.intent = null; + session.name = null; + session.dateISO = null; + session.time24 = null; + session.people = null; + session.waTo = null; + session.fromCaller = from; + resetRetries(session); + sessions.set(callSid, session); const action = `${BASE_URL}/voice/step`; + const body = twiml(` -${say("Ciao! Hai chiamato TuttiBrilli. Ti aiuto con la prenotazione.")} -${gather({ +${say("Ciao! Hai chiamato TuttiBrilli Enoteca.")} +${pause(1)} +${gatherSpeech({ action, - numDigits: 1, - timeout: 8, - prompt: "Premi 1 per prenotare. Premi 2 per informazioni." + prompt: "Vuoi prenotare un tavolo, oppure ti servono informazioni?", + hints: "prenotare, prenotazione, tavolo, posti, informazioni, orari, indirizzo", })} -${say("Non ho ricevuto risposta. Riproviamo.")} +${say("Scusami, non ti ho sentito. Riproviamo.")} ${redirect(`${BASE_URL}/voice`)} `); @@ -128,231 +348,367 @@ ${redirect(`${BASE_URL}/voice`)} // STEP HANDLER app.post("/voice/step", async (req, res) => { - const callSid = req.body.CallSid; - const digits = (req.body.Digits || "").trim(); + const callSid = req.body.CallSid || `local-${Date.now()}`; + const session = getSession(callSid); + + const speechRaw = (req.body.SpeechResult || "").trim(); + const speech = normalizeText(speechRaw); + const confidence = parseFloat(req.body.Confidence || "0"); - const session = sessions.get(callSid) || { step: 1 }; + const action = `${BASE_URL}/voice/step`; + + function respond(xml) { + return res.type("text/xml").status(200).send(twiml(xml)); + } + + function failOrRetry({ prompt1, prompt2, exitPrompt }) { + const n = incRetry(session); + + if (n === 1) { + return respond(` +${gatherSpeech({ action, prompt: prompt1 })} +${redirect(action)} + `); + } + if (n === 2) { + return respond(` +${gatherSpeech({ action, prompt: prompt2 })} +${redirect(action)} + `); + } + + // 3°: uscita soft + sessions.delete(callSid); + return respond(` +${say(exitPrompt)} +${hangup()} + `); + } try { - const action = `${BASE_URL}/voice/step`; + // Se Twilio non ha captato niente (no speech) + if (!speech) { + // Retry generico basato sullo step + if (session.step === 1) { + return failOrRetry({ + prompt1: "Dimmi pure: vuoi prenotare o informazioni?", + prompt2: "Puoi dire, per esempio: 'voglio prenotare un tavolo'.", + exitPrompt: "Non riesco a sentirti bene. Se vuoi, scrivici su WhatsApp. A presto!", + }); + } + if (session.step === 2) { + return failOrRetry({ + prompt1: "Come ti chiami?", + prompt2: "Dimmi il tuo nome, ad esempio: 'Mario Rossi'.", + exitPrompt: "Perfetto, ci sentiamo più tardi. Se vuoi, scrivici su WhatsApp. A presto!", + }); + } + if (session.step === 3) { + return failOrRetry({ + prompt1: "Per che giorno vuoi prenotare?", + prompt2: "Puoi dire 'domani' oppure '25 12'.", + exitPrompt: "Non riesco a prendere la data. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", + }); + } + if (session.step === 4) { + return failOrRetry({ + prompt1: "A che ora preferisci?", + prompt2: "Puoi dire '20 e 30' oppure '21'.", + exitPrompt: "Non riesco a prendere l'orario. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", + }); + } + if (session.step === 5) { + return failOrRetry({ + prompt1: "Per quante persone?", + prompt2: "Dimmi un numero, ad esempio 'quattro'.", + exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", + }); + } + if (session.step === 6) { + return failOrRetry({ + prompt1: "A che numero WhatsApp vuoi ricevere la conferma? Dimmi il numero iniziando con più trentanove.", + prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", + exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", + }); + } + } + + // Confidence molto bassa: trattiamo come “non capito” + if (!Number.isNaN(confidence) && confidence > 0 && confidence < 0.35) { + // Non blocchiamo sempre, ma preferiamo ripetere + // (non facciamo overfitting: Twilio confidence non è sempre affidabile) + } - // STEP 1: scelta + // ---------------- + // STEP 1: Intento + // ---------------- if (session.step === 1) { - if (digits === "1") { + if (looksLikeBooking(speech)) { + session.intent = "booking"; session.step = 2; + resetRetries(session); sessions.set(callSid, session); - const body = twiml(` -${say("Perfetto. Iniziamo.")} -${gather({ - action, - timeout: 10, - finishOnKey: "#", - prompt: - "Inserisci il tuo nome e cognome usando i tasti del telefono, poi premi cancelletto. " + - "Se non vuoi, premi subito cancelletto e andiamo avanti." -})} + return respond(` +${say("Perfetto. Ti faccio qualche domanda veloce.")} +${pause(1)} +${gatherSpeech({ action, prompt: "Come ti chiami?" })} ${redirect(action)} `); - - return res.type("text/xml").status(200).send(body); } - if (digits === "2") { - const body = twiml(` -${say("Per informazioni puoi scriverci su WhatsApp. Grazie!")} + if (looksLikeInfo(speech)) { + sessions.delete(callSid); + return respond(` +${say("Certo. Per informazioni rapide, scrivici su WhatsApp. Se invece vuoi prenotare, dimmelo e ti aiuto subito.")} ${hangup()} `); - sessions.delete(callSid); - return res.type("text/xml").status(200).send(body); } - const body = twiml(` -${say("Scelta non valida.")} -${redirect(`${BASE_URL}/voice`)} - `); - return res.type("text/xml").status(200).send(body); + return failOrRetry({ + prompt1: "Scusami, vuoi prenotare un tavolo o informazioni?", + prompt2: "Puoi dire: 'prenotare un tavolo' oppure 'informazioni'.", + exitPrompt: "Va bene. Scrivici su WhatsApp e ti rispondiamo appena possibile. A presto!", + }); } - // STEP 2: nome (DTMF grezzo, ok per test) + // ---------------- + // STEP 2: Nome + // ---------------- if (session.step === 2) { - session.name = digits ? digits : "(nome da confermare)"; + session.name = speechRaw || speech || "(nome da confermare)"; session.step = 3; + resetRetries(session); sessions.set(callSid, session); - const body = twiml(` -${gather({ - action, - numDigits: 6, - timeout: 12, - prompt: - "Inserisci la data in formato G G M M A A. " + - "Esempio: 2 5 1 2 2 5 per 25 dicembre 2025." -})} -${say("Non ho ricevuto la data.")} + return respond(` +${say(`Piacere, ${session.name}.`)} +${pause(1)} +${gatherSpeech({ action, prompt: "Per che giorno vuoi prenotare?" })} ${redirect(action)} `); - - return res.type("text/xml").status(200).send(body); } - // STEP 3: data DDMMAA + // ---------------- + // STEP 3: Data + // ---------------- if (session.step === 3) { - if (!digits || digits.length !== 6) { - const body = twiml(` -${say("Formato data non valido.")} -${redirect(action)} - `); - return res.type("text/xml").status(200).send(body); + const dateISO = parseDateIT_MVP(speech); + if (!dateISO) { + return failOrRetry({ + prompt1: "Non sono sicuro di aver capito la data. Per che giorno vuoi prenotare?", + prompt2: "Puoi dire 'domani' oppure '25 12'.", + exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", + }); } - session.date = digits; + session.dateISO = dateISO; session.step = 4; + resetRetries(session); sessions.set(callSid, session); - const body = twiml(` -${gather({ - action, - numDigits: 4, - timeout: 12, - prompt: - "Inserisci l'orario in formato O O M M. " + - "Esempio: 2 0 3 0 per 20 e 30." -})} -${say("Non ho ricevuto l'orario.")} + return respond(` +${say(`Ok, ${humanDateIT(session.dateISO)}.`)} +${pause(1)} +${gatherSpeech({ action, prompt: "A che ora preferisci?" })} ${redirect(action)} `); - - return res.type("text/xml").status(200).send(body); } - // STEP 4: ora HHMM + // ---------------- + // STEP 4: Orario + // ---------------- if (session.step === 4) { - if (!digits || digits.length !== 4) { - const body = twiml(` -${say("Formato orario non valido.")} -${redirect(action)} - `); - return res.type("text/xml").status(200).send(body); + const time24 = parseTimeIT_MVP(speech); + if (!time24) { + return failOrRetry({ + prompt1: "Non sono sicuro di aver capito l'orario. A che ora preferisci?", + prompt2: "Puoi dire '20 e 30' oppure '21'.", + exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", + }); } - session.time = digits; + session.time24 = time24; session.step = 5; + resetRetries(session); sessions.set(callSid, session); - const body = twiml(` -${gather({ - action, - numDigits: 2, - timeout: 10, - prompt: "Quante persone? Inserisci 1 o 2 cifre." -})} -${say("Non ho ricevuto il numero di persone.")} + return respond(` +${say(`Perfetto, alle ${session.time24}.`)} +${pause(1)} +${gatherSpeech({ action, prompt: "Per quante persone?" })} ${redirect(action)} `); - - return res.type("text/xml").status(200).send(body); } - // STEP 5: persone + // ---------------- + // STEP 5: Persone + // ---------------- if (session.step === 5) { - if (!digits) { - const body = twiml(` -${say("Numero di persone non valido.")} + const people = parsePeopleIT_MVP(speech); + if (!people) { + return failOrRetry({ + prompt1: "Quante persone sarete?", + prompt2: "Dimmi un numero, ad esempio 'quattro'.", + exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", + }); + } + + session.people = people; + + // STEP 6: WhatsApp (consenso/numero) + session.step = 6; + resetRetries(session); + + // Se From è un +39 mobile plausibile, chiediamo consenso e usiamo quello. + const from = String(session.fromCaller || ""); + const fromE164 = from.startsWith("+") ? from : ""; + const canUseCaller = isLikelyItalianMobileE164(fromE164); + + sessions.set(callSid, session); + + if (canUseCaller) { + // Step 6a: chiedi conferma sì/no sul numero chiamante + session.substep = "wa_confirm_caller"; + sessions.set(callSid, session); + + return respond(` +${say(`Perfetto. Ricapitolo: ${humanDateIT(session.dateISO)} alle ${session.time24}, per ${session.people} persone.`)} +${pause(1)} +${gatherSpeech({ + action, + prompt: "Ti mando la conferma su WhatsApp a questo numero. Va bene?", + hints: "sì, si, va bene, ok, certo, no, cambia, un altro numero", +})} ${redirect(action)} `); - return res.type("text/xml").status(200).send(body); } - session.people = digits; - session.step = 6; + // Caller non affidabile -> chiedi numero + session.substep = "wa_ask_number"; sessions.set(callSid, session); - const body = twiml(` -${gather({ + return respond(` +${say(`Perfetto. Ricapitolo: ${humanDateIT(session.dateISO)} alle ${session.time24}, per ${session.people} persone.`)} +${pause(1)} +${gatherSpeech({ action, - timeout: 15, - finishOnKey: "#", - prompt: - "Ora inserisci il tuo numero WhatsApp con prefisso. " + - "Esempio: 3 9 3 ... Poi premi cancelletto." + prompt: "A che numero WhatsApp vuoi ricevere la conferma? Dimmi il numero iniziando con più trentanove.", })} -${say("Non ho ricevuto il numero WhatsApp.")} ${redirect(action)} `); - - return res.type("text/xml").status(200).send(body); } - // STEP 6: whatsapp + invio WA + // ---------------- + // STEP 6: gestione WhatsApp (consenso o numero) + invio + // ---------------- if (session.step === 6) { - const waTo = normalizeWhatsapp(digits); - - // Controlli minimi - if (!waTo || waTo.length < 14) { - const body = twiml(` -${say("Numero WhatsApp non valido. Riproviamo.")} + const sub = session.substep || "wa_ask_number"; + + // 6a: conferma numero chiamante + if (sub === "wa_confirm_caller") { + const yes = /\b(si|sì|ok|va bene|certo|confermo)\b/.test(speech); + const no = /\b(no|non va bene|cambia|altro numero)\b/.test(speech); + + if (yes && !no) { + session.waTo = `whatsapp:${session.fromCaller}`; // fromCaller già +39... + session.substep = null; + session.step = 7; + resetRetries(session); + sessions.set(callSid, session); + // vai a invio + } else if (no && !yes) { + session.substep = "wa_ask_number"; + resetRetries(session); + sessions.set(callSid, session); + + return respond(` +${gatherSpeech({ + action, + prompt: "Ok. Dimmi il numero WhatsApp, iniziando con più trentanove.", +})} ${redirect(action)} - `); - return res.type("text/xml").status(200).send(body); + `); + } else { + return failOrRetry({ + prompt1: "Scusami, ti va bene che invii il WhatsApp a questo numero? Puoi dire sì o no.", + prompt2: "Dimmi solo: sì, va bene. Oppure: no, un altro numero.", + exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", + }); + } } - session.whatsapp = waTo; - session.step = 7; - sessions.set(callSid, session); - - // Costruisci riepilogo - const humanDate = ddmmaaToHuman(session.date); - const humanTime = hhmmToHuman(session.time); + // 6b: acquisizione numero WhatsApp a voce + if (sub === "wa_ask_number") { + const waTo = normalizeWhatsappFromVoice(speechRaw || speech); + if (!waTo || !hasValidWaAddress(waTo)) { + return failOrRetry({ + prompt1: "Non sono sicuro di aver capito il numero. Me lo ripeti iniziando con più trentanove?", + prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", + exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", + }); + } + + session.waTo = waTo; + session.substep = null; + session.step = 7; + resetRetries(session); + sessions.set(callSid, session); + // vai a invio + } + } - const message = -`✅ *Richiesta prenotazione ricevuta* -Nome: ${session.name} -Data: ${humanDate} -Ora: ${humanTime} + // ---------------- + // STEP 7: invio WhatsApp + chiusura + // ---------------- + if (session.step === 7) { + const waTo = session.waTo; + + const summary = +`✅ Richiesta prenotazione ricevuta +Nome: ${session.name || "-"} +Data: ${humanDateIT(session.dateISO)} +Ora: ${session.time24} Persone: ${session.people} -Rispondi a questo WhatsApp per *confermare* o *modificare*.`; +Rispondi qui se devi modificare o annullare.`; - // Invia WhatsApp (se configurato) if (!twilioClient) { console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); } else if (!TWILIO_WHATSAPP_FROM) { console.error("Manca TWILIO_WHATSAPP_FROM (es. whatsapp:+14155238886)"); + } else if (!waTo || !hasValidWaAddress(waTo)) { + console.error("waTo non valido:", waTo); } else { await twilioClient.messages.create({ from: TWILIO_WHATSAPP_FROM, to: waTo, - body: message, + body: summary, }); } - const body = twiml(` -${say("Perfetto. Ti ho inviato un messaggio WhatsApp con il riepilogo. Grazie!")} + sessions.delete(callSid); + return respond(` +${say("Perfetto! Ho preso la richiesta. Ti ho inviato un WhatsApp con il riepilogo. A presto da TuttiBrilli!")} ${hangup()} `); - - sessions.delete(callSid); - return res.type("text/xml").status(200).send(body); } - // fallback + // fallback finale sessions.delete(callSid); - const body = twiml(` + return respond(` ${say("Ripartiamo da capo.")} ${redirect(`${BASE_URL}/voice`)} `); - return res.type("text/xml").status(200).send(body); } catch (err) { console.error("VOICE FLOW ERROR:", err); - - const body = twiml(` + sessions.delete(callSid); + return res.type("text/xml").status(200).send( + twiml(` ${say("C'è stato un problema tecnico. Riprova tra poco.")} ${hangup()} - `); - - sessions.delete(callSid); - return res.type("text/xml").status(200).send(body); + `) + ); } }); From b463d48c5ac83bc3cfd1fad9ba870ff3f5adbc08 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 21 Dec 2025 22:35:40 +0100 Subject: [PATCH 015/143] Update app.js --- app.js | 252 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 160 insertions(+), 92 deletions(-) diff --git a/app.js b/app.js index e942b76b33..d88e888e99 100644 --- a/app.js +++ b/app.js @@ -13,15 +13,37 @@ const BASE_URL = process.env.BASE_URL || ""; // es: https://ai-backoffice-tuttib const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; -const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ +const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ -// Client Twilio +const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ""; +const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; // es: ...@group.calendar.google.com +const DEFAULT_EVENT_DURATION_MINUTES = parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || "120", 10); + +// Twilio client let twilioClient = null; if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { const twilio = require("twilio"); twilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); } +// Google Calendar client (Service Account) +const { google } = require("googleapis"); + +function getCalendarClient() { + if (!GOOGLE_SERVICE_ACCOUNT_JSON) return null; + if (!GOOGLE_CALENDAR_ID) return null; + + const creds = JSON.parse(GOOGLE_SERVICE_ACCOUNT_JSON); + + const auth = new google.auth.JWT({ + email: creds.client_email, + key: creds.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + }); + + return google.calendar({ version: "v3", auth }); +} + // -------------------- // Pagine base // -------------------- @@ -45,9 +67,6 @@ function twiml(xmlInsideResponseTag) { return `\n\n${xmlInsideResponseTag}\n`; } -// Se vuoi provare voci diverse (se disponibili sul tuo account): -// - voice="alice" (standard) -// - voice="Polly.Bianca" / "Polly.Bianca-Neural" (se abilitato) function say(text) { const safe = xmlEscape(text); return `${safe}`; @@ -65,12 +84,6 @@ function hangup() { return ``; } -/** - * Gather speech-only. - * - timeout: secondi di attesa prima di "no input" - * - speechTimeout: "auto" o numero (sec) di silenzio per chiudere - * - hints: parole chiave (non obbligatorio) - */ function gatherSpeech({ action, method = "POST", @@ -99,6 +112,7 @@ function gatherSpeech({ * session schema: * { * step: number, + * substep?: string|null, * retries: number, * intent: "booking"|"info"|null, * name: string|null, @@ -114,7 +128,18 @@ const sessions = new Map(); // key: CallSid function getSession(callSid) { const s = sessions.get(callSid); if (s) return s; - const fresh = { step: 1, retries: 0, intent: null, name: null, dateISO: null, time24: null, people: null, waTo: null, fromCaller: null }; + const fresh = { + step: 1, + substep: null, + retries: 0, + intent: null, + name: null, + dateISO: null, + time24: null, + people: null, + waTo: null, + fromCaller: null, + }; sessions.set(callSid, fresh); return fresh; } @@ -146,8 +171,7 @@ function looksLikeInfo(text) { return /info|orari|indirizz|dove|menu|menù|carta|vini|evento|serata/.test(text); } -function nowRome() { - // Manteniamo semplice: usa timezone server. Per robustezza futura: luxon. +function nowLocal() { return new Date(); } @@ -159,13 +183,10 @@ function toISODate(d) { } function parseDateIT_MVP(speech) { - // Supporta: "oggi", "stasera" -> oggi; "domani" -> domani - // Supporta: "25/12/2025", "25-12-2025", "25/12", "2512", "25 12" - // Se manca anno -> anno corrente const t = normalizeText(speech); if (!t) return null; - const now = nowRome(); + const now = nowLocal(); if (/\b(oggi|stasera)\b/.test(t)) return toISODate(now); if (/\bdomani\b/.test(t)) { @@ -174,7 +195,6 @@ function parseDateIT_MVP(speech) { return toISODate(d); } - // Estrai numeri // formati tipo 25/12/2025 o 25-12-2025 let m = t.match(/\b(\d{1,2})[\/\-\.](\d{1,2})(?:[\/\-\.](\d{2,4}))?\b/); if (m) { @@ -185,13 +205,12 @@ function parseDateIT_MVP(speech) { if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { const d = new Date(yy, mm - 1, dd); - // Validazione semplice (evita 31/02) if (d.getFullYear() === yy && d.getMonth() === mm - 1 && d.getDate() === dd) return toISODate(d); } return null; } - // formati "2512" o "25 12" (senza anno) + // "2512" o "25 12" const digits = t.replace(/[^\d]/g, ""); if (digits.length === 4) { const dd = parseInt(digits.slice(0, 2), 10); @@ -207,7 +226,6 @@ function parseDateIT_MVP(speech) { } function parseTimeIT_MVP(speech) { - // Supporta: "20:30", "20 e 30", "20 30", "2030", "alle 20", "ore 20" const t = normalizeText(speech); if (!t) return null; @@ -222,17 +240,17 @@ function parseTimeIT_MVP(speech) { return null; } - // "20 e 30" - m = t.match(/\b(\d{1,2})\s*(?:e|e\s+le)?\s*(\d{1,2})\b/); + // "20 e 30" / "20 30" + m = t.match(/\b(\d{1,2})\s*(?:e)?\s*(\d{1,2})\b/); if (m) { const hh = parseInt(m[1], 10); - let mm = parseInt(m[2], 10); + const mm = parseInt(m[2], 10); if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; } } - // digits "2030" oppure "830" (rischioso) -> gestiamo solo 3-4 cifre + // digits "2030" const digits = t.replace(/[^\d]/g, ""); if (digits.length === 4) { const hh = parseInt(digits.slice(0, 2), 10); @@ -253,8 +271,6 @@ function parseTimeIT_MVP(speech) { } function parsePeopleIT_MVP(speech) { - // Cerca un numero nel testo (es. "siamo in quattro" -> 4 se dice "4") - // MVP: estrae cifre. (Estendibile con mapping "due, tre, quattro") const t = normalizeText(speech); if (!t) return null; @@ -264,7 +280,6 @@ function parsePeopleIT_MVP(speech) { if (n >= 1 && n <= 20) return n; } - // mapping minimo parole const map = { uno: 1, una: 1, due: 2, @@ -275,7 +290,7 @@ function parsePeopleIT_MVP(speech) { sette: 7, otto: 8, nove: 9, - dieci: 10 + dieci: 10, }; for (const [k, v] of Object.entries(map)) { if (new RegExp(`\\b${k}\\b`).test(t)) return v; @@ -291,18 +306,14 @@ function humanDateIT(iso) { } function normalizeWhatsappFromVoice(speechOrDigits) { - // Per voce: spesso arriva "tre nove tre ..." ma Twilio restituisce testo. - // MVP: estraiamo tutte le cifre dal testo. const raw = String(speechOrDigits || "").replace(/[^\d]/g, ""); if (!raw) return ""; if (raw.startsWith("39")) return `whatsapp:+${raw}`; if (raw.startsWith("3")) return `whatsapp:+39${raw}`; - if (raw.startsWith("0")) return ""; // numeri fissi/ambigui: richiedi di ripetere con +39 return `whatsapp:+${raw}`; } function isLikelyItalianMobileE164(e164) { - // +39 + 3xxxxxxxxx (approssimazione) return /^\+39\d{9,12}$/.test(e164 || ""); } @@ -310,15 +321,90 @@ function hasValidWaAddress(wa) { return /^whatsapp:\+\d{8,15}$/.test(wa || ""); } +// -------------------- +// Google Calendar: crea evento prenotazione (con idempotenza su CallSid) +// -------------------- +function addMinutes(date, minutes) { + return new Date(date.getTime() + minutes * 60 * 1000); +} + +function toLocalDateTimeParts(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const mm = String(date.getMinutes()).padStart(2, "0"); + return { date: `${y}-${m}-${d}`, time: `${hh}:${mm}` }; +} + +async function createBookingEvent({ + callSid, + name, + dateISO, + time24, + people, + phone, + waTo, +}) { + const calendar = getCalendarClient(); + if (!calendar) throw new Error("Google Calendar non configurato (manca JSON o CALENDAR_ID)."); + + const privateKey = `callsid:${callSid}`; + + // Idempotenza: cerca se esiste già un evento con callsid: nella description + const existing = await calendar.events.list({ + calendarId: GOOGLE_CALENDAR_ID, + q: privateKey, + // limitiamo ricerca alla giornata per velocità + timeMin: `${dateISO}T00:00:00Z`, + timeMax: `${dateISO}T23:59:59Z`, + singleEvents: true, + maxResults: 5, + }); + + const found = (existing.data.items || []).find((ev) => (ev.description || "").includes(privateKey)); + if (found) { + return { eventId: found.id, htmlLink: found.htmlLink, reused: true }; + } + + const startDateTime = `${dateISO}T${time24}:00`; + const start = new Date(`${dateISO}T${time24}:00`); + const end = addMinutes(start, DEFAULT_EVENT_DURATION_MINUTES); + const endParts = toLocalDateTimeParts(end); + const endDateTime = `${endParts.date}T${endParts.time}:00`; + + const requestBody = { + summary: `TuttiBrilli – ${name} – ${people} pax`, + description: + `Prenotazione\n` + + `Nome: ${name}\n` + + `Persone: ${people}\n` + + `Telefono: ${phone || "-"}\n` + + `WhatsApp: ${waTo || "-"}\n` + + `${privateKey}\n`, + start: { dateTime: startDateTime, timeZone: "Europe/Rome" }, + end: { dateTime: endDateTime, timeZone: "Europe/Rome" }, + }; + + const resp = await calendar.events.insert({ + calendarId: GOOGLE_CALENDAR_ID, + requestBody, + }); + + return { eventId: resp.data.id, htmlLink: resp.data.htmlLink, reused: false }; +} + // -------------------- // TWILIO VOICE - START // -------------------- app.post("/voice", (req, res) => { const callSid = req.body.CallSid || `local-${Date.now()}`; - const from = req.body.From || ""; // caller id (può essere il numero del forwarder) + const from = req.body.From || ""; // caller id (può essere forwarder) const session = getSession(callSid); session.step = 1; + session.substep = null; + session.retries = 0; session.intent = null; session.name = null; session.dateISO = null; @@ -326,7 +412,6 @@ app.post("/voice", (req, res) => { session.people = null; session.waTo = null; session.fromCaller = from; - resetRetries(session); sessions.set(callSid, session); const action = `${BASE_URL}/voice/step`; @@ -365,19 +450,20 @@ app.post("/voice/step", async (req, res) => { const n = incRetry(session); if (n === 1) { + sessions.set(callSid, session); return respond(` ${gatherSpeech({ action, prompt: prompt1 })} ${redirect(action)} `); } if (n === 2) { + sessions.set(callSid, session); return respond(` ${gatherSpeech({ action, prompt: prompt2 })} ${redirect(action)} `); } - // 3°: uscita soft sessions.delete(callSid); return respond(` ${say(exitPrompt)} @@ -386,9 +472,8 @@ ${hangup()} } try { - // Se Twilio non ha captato niente (no speech) + // No input / no speech if (!speech) { - // Retry generico basato sullo step if (session.step === 1) { return failOrRetry({ prompt1: "Dimmi pure: vuoi prenotare o informazioni?", @@ -400,7 +485,7 @@ ${hangup()} return failOrRetry({ prompt1: "Come ti chiami?", prompt2: "Dimmi il tuo nome, ad esempio: 'Mario Rossi'.", - exitPrompt: "Perfetto, ci sentiamo più tardi. Se vuoi, scrivici su WhatsApp. A presto!", + exitPrompt: "Ok. Se vuoi, scrivici su WhatsApp. A presto!", }); } if (session.step === 3) { @@ -433,15 +518,7 @@ ${hangup()} } } - // Confidence molto bassa: trattiamo come “non capito” - if (!Number.isNaN(confidence) && confidence > 0 && confidence < 0.35) { - // Non blocchiamo sempre, ma preferiamo ripetere - // (non facciamo overfitting: Twilio confidence non è sempre affidabile) - } - - // ---------------- // STEP 1: Intento - // ---------------- if (session.step === 1) { if (looksLikeBooking(speech)) { session.intent = "booking"; @@ -460,7 +537,7 @@ ${redirect(action)} if (looksLikeInfo(speech)) { sessions.delete(callSid); return respond(` -${say("Certo. Per informazioni rapide, scrivici su WhatsApp. Se invece vuoi prenotare, dimmelo e ti aiuto subito.")} +${say("Certo. Per informazioni rapide puoi scriverci su WhatsApp. Se invece vuoi prenotare, dimmelo e ti aiuto subito.")} ${hangup()} `); } @@ -472,11 +549,9 @@ ${hangup()} }); } - // ---------------- // STEP 2: Nome - // ---------------- if (session.step === 2) { - session.name = speechRaw || speech || "(nome da confermare)"; + session.name = speechRaw || "(nome da confermare)"; session.step = 3; resetRetries(session); sessions.set(callSid, session); @@ -489,9 +564,7 @@ ${redirect(action)} `); } - // ---------------- // STEP 3: Data - // ---------------- if (session.step === 3) { const dateISO = parseDateIT_MVP(speech); if (!dateISO) { @@ -515,9 +588,7 @@ ${redirect(action)} `); } - // ---------------- // STEP 4: Orario - // ---------------- if (session.step === 4) { const time24 = parseTimeIT_MVP(speech); if (!time24) { @@ -541,9 +612,7 @@ ${redirect(action)} `); } - // ---------------- // STEP 5: Persone - // ---------------- if (session.step === 5) { const people = parsePeopleIT_MVP(speech); if (!people) { @@ -555,20 +624,16 @@ ${redirect(action)} } session.people = people; - - // STEP 6: WhatsApp (consenso/numero) session.step = 6; + session.substep = null; resetRetries(session); + sessions.set(callSid, session); - // Se From è un +39 mobile plausibile, chiediamo consenso e usiamo quello. const from = String(session.fromCaller || ""); const fromE164 = from.startsWith("+") ? from : ""; const canUseCaller = isLikelyItalianMobileE164(fromE164); - sessions.set(callSid, session); - if (canUseCaller) { - // Step 6a: chiedi conferma sì/no sul numero chiamante session.substep = "wa_confirm_caller"; sessions.set(callSid, session); @@ -584,7 +649,6 @@ ${redirect(action)} `); } - // Caller non affidabile -> chiedi numero session.substep = "wa_ask_number"; sessions.set(callSid, session); @@ -599,34 +663,27 @@ ${redirect(action)} `); } - // ---------------- - // STEP 6: gestione WhatsApp (consenso o numero) + invio - // ---------------- + // STEP 6: WhatsApp (consenso o numero) if (session.step === 6) { const sub = session.substep || "wa_ask_number"; - // 6a: conferma numero chiamante if (sub === "wa_confirm_caller") { const yes = /\b(si|sì|ok|va bene|certo|confermo)\b/.test(speech); const no = /\b(no|non va bene|cambia|altro numero)\b/.test(speech); if (yes && !no) { session.waTo = `whatsapp:${session.fromCaller}`; // fromCaller già +39... - session.substep = null; session.step = 7; + session.substep = null; resetRetries(session); sessions.set(callSid, session); - // vai a invio + // continua a STEP 7 sotto } else if (no && !yes) { session.substep = "wa_ask_number"; resetRetries(session); sessions.set(callSid, session); - return respond(` -${gatherSpeech({ - action, - prompt: "Ok. Dimmi il numero WhatsApp, iniziando con più trentanove.", -})} +${gatherSpeech({ action, prompt: "Ok. Dimmi il numero WhatsApp, iniziando con più trentanove." })} ${redirect(action)} `); } else { @@ -638,7 +695,6 @@ ${redirect(action)} } } - // 6b: acquisizione numero WhatsApp a voce if (sub === "wa_ask_number") { const waTo = normalizeWhatsappFromVoice(speechRaw || speech); if (!waTo || !hasValidWaAddress(waTo)) { @@ -650,28 +706,38 @@ ${redirect(action)} } session.waTo = waTo; - session.substep = null; session.step = 7; + session.substep = null; resetRetries(session); sessions.set(callSid, session); - // vai a invio + // continua a STEP 7 sotto } } - // ---------------- - // STEP 7: invio WhatsApp + chiusura - // ---------------- + // STEP 7: crea evento su Calendar + invia WhatsApp if (session.step === 7) { - const waTo = session.waTo; + // 1) Google Calendar + let calendarResult = null; + try { + calendarResult = await createBookingEvent({ + callSid, + name: session.name, + dateISO: session.dateISO, + time24: session.time24, + people: session.people, + phone: (session.fromCaller || "").startsWith("+") ? session.fromCaller : "", + waTo: session.waTo, + }); + } catch (e) { + console.error("Google Calendar insert failed:", e); + } - const summary = -`✅ Richiesta prenotazione ricevuta -Nome: ${session.name || "-"} -Data: ${humanDateIT(session.dateISO)} -Ora: ${session.time24} -Persone: ${session.people} + // 2) WhatsApp + const waTo = session.waTo; -Rispondi qui se devi modificare o annullare.`; + const waBody = calendarResult + ? `✅ Prenotazione registrata\nNome: ${session.name}\nData: ${humanDateIT(session.dateISO)}\nOra: ${session.time24}\nPersone: ${session.people}\n\nSe devi modificare o annullare, rispondi a questo messaggio.` + : `✅ Richiesta ricevuta\nNome: ${session.name}\nData: ${humanDateIT(session.dateISO)}\nOra: ${session.time24}\nPersone: ${session.people}\n\nTi confermiamo a breve.`; if (!twilioClient) { console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); @@ -683,13 +749,13 @@ Rispondi qui se devi modificare o annullare.`; await twilioClient.messages.create({ from: TWILIO_WHATSAPP_FROM, to: waTo, - body: summary, + body: waBody, }); } sessions.delete(callSid); return respond(` -${say("Perfetto! Ho preso la richiesta. Ti ho inviato un WhatsApp con il riepilogo. A presto da TuttiBrilli!")} +${say("Perfetto! Ho registrato la prenotazione e ti ho inviato un WhatsApp di conferma. A presto da TuttiBrilli!")} ${hangup()} `); } @@ -718,4 +784,6 @@ ${hangup()} app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`BASE_URL: ${BASE_URL || "(non impostato)"}`); + console.log(`Calendar configured: ${Boolean(GOOGLE_SERVICE_ACCOUNT_JSON && GOOGLE_CALENDAR_ID)}`); + console.log(`Twilio configured: ${Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN)}`); }); From 695738fd1138cbd30fee69fe8b5e1cfbfa647348 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 21 Dec 2025 22:42:30 +0100 Subject: [PATCH 016/143] Update package.json --- package.json | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 43954fe280..ca0c9e457d 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { - "name": "express-hello-world", + "name": "ai-backoffice-tuttibrilli", "version": "1.0.0", - "description": "Express Hello World on Render", + "description": "Assistente vocale AI per TuttiBrilli (Twilio + Google Calendar)", "main": "app.js", - "repository": "https://github.com/render-examples/express-hello-world", - "author": "Render Developers", - "license": "MIT", - "private": false, "scripts": { "start": "node app.js" }, + "engines": { + "node": ">=18" + }, "dependencies": { - "express": "^5.0.0" + "express": "^4.19.2", + "twilio": "^5.0.4", + "googleapis": "^133.0.0" } } From c9a88904b8ffa9a2b511fc57d0b8d453ce271500 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 21 Dec 2025 22:46:56 +0100 Subject: [PATCH 017/143] Update yarn.lock --- yarn.lock | 572 ------------------------------------------------------ 1 file changed, 572 deletions(-) diff --git a/yarn.lock b/yarn.lock index 35c2bede4d..8b13789179 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,573 +1 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - -accepts@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" - integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== - dependencies: - mime-types "^3.0.0" - negotiator "^1.0.0" - -array-flatten@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-3.0.0.tgz#6428ca2ee52c7b823192ec600fa3ed2f157cd541" - integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== - -body-parser@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.0.1.tgz#979de4a43468c5624403457fd6d45f797faffbaf" - integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== - dependencies: - bytes "3.1.2" - content-type "~1.0.5" - debug "3.1.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.5.2" - on-finished "2.4.1" - qs "6.13.0" - raw-body "^3.0.0" - type-is "~1.6.18" - unpipe "1.0.0" - -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - -call-bind@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" - -content-disposition@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" - integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== - dependencies: - safe-buffer "5.2.1" - -content-type@^1.0.5, content-type@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - -cookie-signature@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.1.tgz#790dea2cce64638c7ae04d9fabed193bd7ccf3b4" - integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== - -cookie@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - -debug@4.3.6: - version "4.3.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" - -debug@^4.3.5: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - -define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" - -depd@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -destroy@1.2.0, destroy@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= - -encodeurl@^2.0.0, encodeurl@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - -es-define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" - integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== - dependencies: - get-intrinsic "^1.2.4" - -es-errors@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" - integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - -escape-html@^1.0.3, escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= - -etag@^1.8.1, etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= - -express@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.0.0.tgz#744f9ec86025a01aeca99e4300aa4fc050d493c7" - integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== - dependencies: - accepts "^2.0.0" - body-parser "^2.0.1" - content-disposition "^1.0.0" - content-type "~1.0.4" - cookie "0.6.0" - cookie-signature "^1.2.1" - debug "4.3.6" - depd "2.0.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "^2.0.0" - fresh "2.0.0" - http-errors "2.0.0" - merge-descriptors "^2.0.0" - methods "~1.1.2" - mime-types "^3.0.0" - on-finished "2.4.1" - once "1.4.0" - parseurl "~1.3.3" - proxy-addr "~2.0.7" - qs "6.13.0" - range-parser "~1.2.1" - router "^2.0.0" - safe-buffer "5.2.1" - send "^1.1.0" - serve-static "^2.1.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "^2.0.0" - utils-merge "1.0.1" - vary "~1.1.2" - -finalhandler@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" - integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - -fresh@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" - integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== - -fresh@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - -gopd@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== - dependencies: - get-intrinsic "^1.1.3" - -has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-proto@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" - integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== - -has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -hasown@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -http-errors@2.0.0, http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -iconv-lite@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" - integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -inherits@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -is-promise@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" - integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= - -media-typer@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" - integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== - -merge-descriptors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" - integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= - -mime-db@1.40.0: - version "1.40.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" - integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-db@^1.53.0: - version "1.53.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - -mime-types@^2.1.35: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime-types@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.0.tgz#148453a900475522d095a445355c074cca4f5217" - integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== - dependencies: - mime-db "^1.53.0" - -mime-types@~2.1.24: - version "2.1.24" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" - integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== - dependencies: - mime-db "1.40.0" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -negotiator@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" - integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== - -object-inspect@^1.13.1: - version "1.13.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" - integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== - -on-finished@2.4.1, on-finished@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -once@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -parseurl@^1.3.3, parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -path-to-regexp@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.1.0.tgz#4d687606ed0be8ed512ba802eb94d620cb1a86f0" - integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== - -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - -qs@6.13.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" - integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== - dependencies: - side-channel "^1.0.6" - -range-parser@^1.2.1, range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" - integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.6.3" - unpipe "1.0.0" - -router@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/router/-/router-2.0.0.tgz#8692720b95de83876870d7bc638dd3c7e1ae8a27" - integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== - dependencies: - array-flatten "3.0.0" - is-promise "4.0.0" - methods "~1.1.2" - parseurl "~1.3.3" - path-to-regexp "^8.0.0" - setprototypeof "1.2.0" - utils-merge "1.0.1" - -safe-buffer@5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -send@^1.0.0, send@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" - integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== - dependencies: - debug "^4.3.5" - destroy "^1.2.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - fresh "^0.5.2" - http-errors "^2.0.0" - mime-types "^2.1.35" - ms "^2.1.3" - on-finished "^2.4.1" - range-parser "^1.2.1" - statuses "^2.0.1" - -serve-static@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" - integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== - dependencies: - encodeurl "^2.0.0" - escape-html "^1.0.3" - parseurl "^1.3.3" - send "^1.0.0" - -set-function-length@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - -side-channel@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" - -statuses@2.0.1, statuses@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - -type-is@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" - integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== - dependencies: - content-type "^1.0.5" - media-typer "^1.1.0" - mime-types "^3.0.0" - -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= - -vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== From 7845919322408a74c31abb93c7760362dba757ae Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 21 Dec 2025 22:53:11 +0100 Subject: [PATCH 018/143] Update yarn.lock --- yarn.lock | 573 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 573 insertions(+) diff --git a/yarn.lock b/yarn.lock index 8b13789179..b0a2043996 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1 +1,574 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + +array-flatten@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-3.0.0.tgz#6428ca2ee52c7b823192ec600fa3ed2f157cd541" + integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== + +body-parser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.0.1.tgz#979de4a43468c5624403457fd6d45f797faffbaf" + integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "3.1.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.5.2" + on-finished "2.4.1" + qs "6.13.0" + raw-body "^3.0.0" + type-is "~1.6.18" + unpipe "1.0.0" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +content-disposition@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" + integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== + dependencies: + safe-buffer "5.2.1" + +content-type@^1.0.5, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +content-type@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + +cookie-signature@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.1.tgz#790dea2cce64638c7ae04d9fabed193bd7ccf3b4" + integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== + +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + +debug@4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + +debug@^4.3.5: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0, destroy@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + +encodeurl@^2.0.0, encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +escape-html@^1.0.3, escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + +etag@^1.8.1, etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + +express@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/express/-/express-5.0.0.tgz#744f9ec86025a01aeca99e4300aa4fc050d493c7" + integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== + dependencies: + accepts "^2.0.0" + body-parser "^2.0.1" + content-disposition "^1.0.0" + content-type "~1.0.4" + cookie "0.6.0" + cookie-signature "^1.2.1" + debug "4.3.6" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "^2.0.0" + fresh "2.0.0" + http-errors "2.0.0" + merge-descriptors "^2.0.0" + methods "~1.1.2" + mime-types "^3.0.0" + on-finished "2.4.1" + once "1.4.0" + parseurl "~1.3.3" + proxy-addr "~2.0.7" + qs "6.13.0" + range-parser "~1.2.1" + router "^2.0.0" + safe-buffer "5.2.1" + send "^1.1.0" + serve-static "^2.1.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "^2.0.0" + utils-merge "1.0.1" + vary "~1.1.2" + +finalhandler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" + integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + +fresh@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +http-errors@2.0.0, http-errors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +iconv-lite@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" + integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +inherits@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-promise@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + +mime-db@1.40.0: + version "1.40.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" + integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-db@^1.53.0: + version "1.53.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" + integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== + +mime-types@^2.1.35: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime-types@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.0.tgz#148453a900475522d095a445355c074cca4f5217" + integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== + dependencies: + mime-db "^1.53.0" + +mime-types@~2.1.24: + version "2.1.24" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" + integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== + dependencies: + mime-db "1.40.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + +on-finished@2.4.1, on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +parseurl@^1.3.3, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-to-regexp@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.1.0.tgz#4d687606ed0be8ed512ba802eb94d620cb1a86f0" + integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== + dependencies: + side-channel "^1.0.6" + +range-parser@^1.2.1, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" + integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.6.3" + unpipe "1.0.0" + +router@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.0.0.tgz#8692720b95de83876870d7bc638dd3c7e1ae8a27" + integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== + dependencies: + array-flatten "3.0.0" + is-promise "4.0.0" + methods "~1.1.2" + parseurl "~1.3.3" + path-to-regexp "^8.0.0" + setprototypeof "1.2.0" + utils-merge "1.0.1" + +safe-buffer@5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +send@^1.0.0, send@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" + integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== + dependencies: + debug "^4.3.5" + destroy "^1.2.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^0.5.2" + http-errors "^2.0.0" + mime-types "^2.1.35" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.1" + +serve-static@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" + integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.0.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +statuses@2.0.1, statuses@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +type-is@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" + integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== + dependencies: + content-type "^1.0.5" + media-typer "^1.1.0" + mime-types "^3.0.0" + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== From 353fd3f0923116273916bce8ff17423fd16e4b93 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 21 Dec 2025 22:10:10 +0000 Subject: [PATCH 019/143] Add twilio and googleapis dependencies --- package.json | 4 +- yarn.lock | 1153 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 850 insertions(+), 307 deletions(-) diff --git a/package.json b/package.json index ca0c9e457d..b239b6933b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "express": "^4.19.2", - "twilio": "^5.0.4", - "googleapis": "^133.0.0" + "googleapis": "^169.0.0", + "twilio": "^5.11.1" } } diff --git a/yarn.lock b/yarn.lock index b0a2043996..f25e2db0fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,78 +2,214 @@ # yarn lockfile v1 -accepts@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" - integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== dependencies: - mime-types "^3.0.0" - negotiator "^1.0.0" + mime-types "~2.1.34" + negotiator "0.6.3" -array-flatten@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-3.0.0.tgz#6428ca2ee52c7b823192ec600fa3ed2f157cd541" - integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" -body-parser@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.0.1.tgz#979de4a43468c5624403457fd6d45f797faffbaf" - integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.12.0: + version "1.13.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" + integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== dependencies: - bytes "3.1.2" + follow-redirects "^1.15.6" + form-data "^4.0.4" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +bignumber.js@^9.0.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + +body-parser@~1.20.3: + version "1.20.4" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f" + integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA== + dependencies: + bytes "~3.1.2" content-type "~1.0.5" - debug "3.1.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.5.2" - on-finished "2.4.1" - qs "6.13.0" - raw-body "^3.0.0" + debug "2.6.9" + depd "2.0.0" + destroy "~1.2.0" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + on-finished "~2.4.1" + qs "~6.14.0" + raw-body "~2.5.3" type-is "~1.6.18" - unpipe "1.0.0" + unpipe "~1.0.0" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== -bytes@3.1.2: +bytes@~3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -call-bind@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: - es-define-property "^1.0.0" es-errors "^1.3.0" function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" -content-disposition@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" - integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== dependencies: - safe-buffer "5.2.1" + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" -content-type@^1.0.5, content-type@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +content-disposition@~0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -cookie-signature@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.1.tgz#790dea2cce64638c7ae04d9fabed193bd7ccf3b4" - integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== +content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" + integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== + +cookie@~0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" -cookie@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + +dayjs@^1.11.9: + version "1.11.19" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" + integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== debug@2.6.9: version "2.6.9" @@ -82,145 +218,206 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - -debug@4.3.6: - version "4.3.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" - -debug@^4.3.5: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== +debug@4: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" -define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -depd@2.0.0: +depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -destroy@1.2.0, destroy@^1.2.0: +destroy@1.2.0, destroy@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -encodeurl@^2.0.0, encodeurl@~2.0.0: +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +encodeurl@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - -es-define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" - integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== - dependencies: - get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -escape-html@^1.0.3, escape-html@~1.0.3: +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= -etag@^1.8.1, etag@~1.8.1: +etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= -express@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.0.0.tgz#744f9ec86025a01aeca99e4300aa4fc050d493c7" - integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== +express@^4.19.2: + version "4.22.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" + integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g== dependencies: - accepts "^2.0.0" - body-parser "^2.0.1" - content-disposition "^1.0.0" + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "~1.20.3" + content-disposition "~0.5.4" content-type "~1.0.4" - cookie "0.6.0" - cookie-signature "^1.2.1" - debug "4.3.6" + cookie "~0.7.1" + cookie-signature "~1.0.6" + debug "2.6.9" depd "2.0.0" encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "^2.0.0" - fresh "2.0.0" - http-errors "2.0.0" - merge-descriptors "^2.0.0" + finalhandler "~1.3.1" + fresh "~0.5.2" + http-errors "~2.0.0" + merge-descriptors "1.0.3" methods "~1.1.2" - mime-types "^3.0.0" - on-finished "2.4.1" - once "1.4.0" + on-finished "~2.4.1" parseurl "~1.3.3" + path-to-regexp "~0.1.12" proxy-addr "~2.0.7" - qs "6.13.0" + qs "~6.14.0" range-parser "~1.2.1" - router "^2.0.0" safe-buffer "5.2.1" - send "^1.1.0" - serve-static "^2.1.0" + send "~0.19.0" + serve-static "~1.16.2" setprototypeof "1.2.0" - statuses "2.0.1" - type-is "^2.0.0" + statuses "~2.0.1" + type-is "~1.6.18" utils-merge "1.0.1" vary "~1.1.2" -finalhandler@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" - integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== +extend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + +finalhandler@~1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" + integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== dependencies: debug "2.6.9" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" - on-finished "2.4.1" + on-finished "~2.4.1" parseurl "~1.3.3" - statuses "2.0.1" + statuses "~2.0.2" unpipe "~1.0.0" +follow-redirects@^1.15.6: + version "1.15.11" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" + integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +form-data@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" - integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== - -fresh@^0.5.2: +fresh@~0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== @@ -230,74 +427,170 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== +gaxios@^7.0.0, gaxios@^7.0.0-rc.4: + version "7.1.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-7.1.3.tgz#c5312f4254abc1b8ab53aef30c22c5229b80b1e1" + integrity sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ== + dependencies: + extend "^3.0.2" + https-proxy-agent "^7.0.1" + node-fetch "^3.3.2" + rimraf "^5.0.1" + +gcp-metadata@^8.0.0: + version "8.1.2" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz#e62e3373ddf41fc727ccc31c55c687b798bee898" + integrity sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== + dependencies: + gaxios "^7.0.0" + google-logging-utils "^1.0.0" + json-bigint "^1.0.0" + +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" es-errors "^1.3.0" + es-object-atoms "^1.1.1" function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" -gopd@^1.0.1: +get-proto@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: - get-intrinsic "^1.1.3" + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" -has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== +glob@^10.3.7: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +google-auth-library@^10.1.0, google-auth-library@^10.2.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-10.5.0.tgz#3f0ebd47173496b91d2868f572bb8a8180c4b561" + integrity sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w== + dependencies: + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + gaxios "^7.0.0" + gcp-metadata "^8.0.0" + google-logging-utils "^1.0.0" + gtoken "^8.0.0" + jws "^4.0.0" + +google-logging-utils@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz#17b71f1f95d266d2ddd356b8f00178433f041b17" + integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== + +googleapis-common@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-8.0.1.tgz#5dc9042ec095d75c841e0e95cbeb17a88c010015" + integrity sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A== dependencies: - es-define-property "^1.0.0" + extend "^3.0.2" + gaxios "^7.0.0-rc.4" + google-auth-library "^10.1.0" + qs "^6.7.0" + url-template "^2.0.8" + +googleapis@^169.0.0: + version "169.0.0" + resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-169.0.0.tgz#7b7fffcdf63ea29943625b6bd9ced17667053d80" + integrity sha512-IOGMG8tljCZSLvYgdojRu6mB10KEsK0J7X62sXXlQz9koe5BUAW+rqkY3qhQM9wXM6hVL3/Hase7XbxoMyeYiQ== + dependencies: + google-auth-library "^10.2.0" + googleapis-common "^8.0.0" -has-proto@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" - integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +gtoken@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-8.0.0.tgz#d67a0e346dd441bfb54ad14040ddc3b632886575" + integrity sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw== + dependencies: + gaxios "^7.0.0" + jws "^4.0.0" has-symbols@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== -hasown@^2.0.0: +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== dependencies: function-bind "^1.1.2" -http-errors@2.0.0, http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== +http-errors@~2.0.0, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" -iconv-lite@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" - integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== +https-proxy-agent@^7.0.1: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== dependencies: - safer-buffer ">= 2.1.2 < 3" + agent-base "^7.1.2" + debug "4" -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== +iconv-lite@~0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" + safer-buffer ">= 2.1.2 < 3" -inherits@2.0.4: +inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -307,25 +600,119 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -is-promise@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" - integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + +jsonwebtoken@^9.0.2: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.0, jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -media-typer@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" - integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== - -merge-descriptors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" - integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== methods@~1.1.2: version "1.1.2" @@ -342,25 +729,13 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-db@^1.53.0: - version "1.53.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - -mime-types@^2.1.35: +mime-types@^2.1.12, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" -mime-types@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.0.tgz#148453a900475522d095a445355c074cca4f5217" - integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== - dependencies: - mime-db "^1.53.0" - mime-types@~2.1.24: version "2.1.24" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" @@ -368,54 +743,91 @@ mime-types@~2.1.24: dependencies: mime-db "1.40.0" +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@^2.1.3: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -negotiator@^1.0.0: +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +node-domexception@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" - integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -object-inspect@^1.13.1: - version "1.13.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" - integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== -on-finished@2.4.1, on-finished@^2.4.1: +on-finished@~2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== dependencies: ee-first "1.1.1" -once@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -parseurl@^1.3.3, parseurl@~1.3.3: +parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -path-to-regexp@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.1.0.tgz#4d687606ed0be8ed512ba802eb94d620cb1a86f0" - integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@~0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" + integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== proxy-addr@~2.0.7: version "2.0.7" @@ -425,124 +837,221 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" -qs@6.13.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" - integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +qs@^6.7.0, qs@^6.9.4, qs@~6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" + integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== dependencies: - side-channel "^1.0.6" + side-channel "^1.1.0" -range-parser@^1.2.1, range-parser@~1.2.1: +range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" - integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== +raw-body@~2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.6.3" - unpipe "1.0.0" + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" -router@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/router/-/router-2.0.0.tgz#8692720b95de83876870d7bc638dd3c7e1ae8a27" - integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== +rimraf@^5.0.1: + version "5.0.10" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" + integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== dependencies: - array-flatten "3.0.0" - is-promise "4.0.0" - methods "~1.1.2" - parseurl "~1.3.3" - path-to-regexp "^8.0.0" - setprototypeof "1.2.0" - utils-merge "1.0.1" + glob "^10.3.7" -safe-buffer@5.2.1: +safe-buffer@5.2.1, safe-buffer@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -send@^1.0.0, send@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" - integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== - dependencies: - debug "^4.3.5" - destroy "^1.2.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - fresh "^0.5.2" - http-errors "^2.0.0" - mime-types "^2.1.35" - ms "^2.1.3" - on-finished "^2.4.1" - range-parser "^1.2.1" - statuses "^2.0.1" - -serve-static@^2.1.0: +scmp@^2.1.0: version "2.1.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" - integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== + resolved "https://registry.yarnpkg.com/scmp/-/scmp-2.1.0.tgz#37b8e197c425bdeb570ab91cc356b311a11f9c9a" + integrity sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q== + +semver@^7.5.4: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +send@~0.19.0, send@~0.19.1: + version "0.19.2" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" + integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== dependencies: - encodeurl "^2.0.0" - escape-html "^1.0.3" - parseurl "^1.3.3" - send "^1.0.0" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "~0.5.2" + http-errors "~2.0.1" + mime "1.6.0" + ms "2.1.3" + on-finished "~2.4.1" + range-parser "~1.2.1" + statuses "~2.0.2" -set-function-length@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== +serve-static@~1.16.2: + version "1.16.3" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9" + integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "~0.19.1" -setprototypeof@1.2.0: +setprototypeof@1.2.0, setprototypeof@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -side-channel@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== dependencies: - call-bind "^1.0.7" es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" + object-inspect "^1.13.3" -statuses@2.0.1, statuses@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +statuses@~2.0.1, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" -toidentifier@1.0.1: +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -type-is@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" - integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== +twilio@^5.11.1: + version "5.11.1" + resolved "https://registry.yarnpkg.com/twilio/-/twilio-5.11.1.tgz#9b2bed75e01f198b9fba2459fe7fff26e40c915b" + integrity sha512-LQuLrAwWk7dsu7S5JQWzLRe17qdD4/7OJcwZG6kYWMJILtxI7pXDHksu9DcIF/vKpSpL1F0/sA9uSF3xuVizMQ== dependencies: - content-type "^1.0.5" - media-typer "^1.1.0" - mime-types "^3.0.0" + axios "^1.12.0" + dayjs "^1.11.9" + https-proxy-agent "^5.0.0" + jsonwebtoken "^9.0.2" + qs "^6.9.4" + scmp "^2.1.0" + xmlbuilder "^13.0.2" type-is@~1.6.18: version "1.6.18" @@ -552,11 +1061,16 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -unpipe@1.0.0, unpipe@~1.0.0: +unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= +url-template@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" + integrity sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw== + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -567,8 +1081,37 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +xmlbuilder@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-13.0.2.tgz#02ae33614b6a047d1c32b5389c1fdacb2bce47a7" + integrity sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ== From 23638a5fe82d50d78371eb30d8f23d9a20159b37 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 24 Dec 2025 17:07:59 +0100 Subject: [PATCH 020/143] Update app.js --- app.js | 118 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 20 deletions(-) diff --git a/app.js b/app.js index d88e888e99..335c38d697 100644 --- a/app.js +++ b/app.js @@ -19,6 +19,11 @@ const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || " const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; // es: ...@group.calendar.google.com const DEFAULT_EVENT_DURATION_MINUTES = parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || "120", 10); +// >>> INOLTRO A OPERATORE (NUOVO) +const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; // es: +393331112222 +const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "true").toLowerCase() === "true"; +const LOW_CONFIDENCE_THRESHOLD = parseFloat(process.env.LOW_CONFIDENCE_THRESHOLD || "0.45"); // regola a piacere + // Twilio client let twilioClient = null; if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { @@ -105,6 +110,44 @@ function gatherSpeech({ `; } +// >>> DIAL/INOLTRO (NUOVO) +function dialNumber(numberE164, { callerId = null, timeout = 20 } = {}) { + // callerId: opzionale, ma spesso conviene lasciare quello di Twilio + const attrs = [ + timeout ? `timeout="${Number(timeout)}"` : "", + callerId ? `callerId="${xmlEscape(callerId)}"` : "", + ] + .filter(Boolean) + .join(" "); + return `${xmlEscape(numberE164)}`; +} + +function wantsHuman(text) { + const t = String(text || "").toLowerCase(); + return /\b(operatore|umano|persona|collega|parlare con qualcuno|assistenza)\b/.test(t); +} + +function canForwardToHuman() { + return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && /^\+\d{8,15}$/.test(HUMAN_FORWARD_TO); +} + +function forwardToHumanTwiml(reason = "richiesta assistenza") { + if (!canForwardToHuman()) { + // se non configurato, chiudiamo in modo elegante + return ` +${say("Va bene. Al momento non riesco a trasferire la chiamata. Ti invito a scriverci su WhatsApp. A presto!")} +${hangup()} + `; + } + return ` +${say("Perfetto, ti passo subito un operatore.")} +${pause(1)} +${dialNumber(HUMAN_FORWARD_TO, { timeout: 25 })} +${say("Non sono riuscito a metterti in contatto. Se vuoi, scrivici su WhatsApp. A presto!")} +${hangup()} + `; +} + // -------------------- // Sessioni in memoria (MVP). In produzione: Redis/DB. // -------------------- @@ -195,7 +238,6 @@ function parseDateIT_MVP(speech) { return toISODate(d); } - // formati tipo 25/12/2025 o 25-12-2025 let m = t.match(/\b(\d{1,2})[\/\-\.](\d{1,2})(?:[\/\-\.](\d{2,4}))?\b/); if (m) { let dd = parseInt(m[1], 10); @@ -210,7 +252,6 @@ function parseDateIT_MVP(speech) { return null; } - // "2512" o "25 12" const digits = t.replace(/[^\d]/g, ""); if (digits.length === 4) { const dd = parseInt(digits.slice(0, 2), 10); @@ -229,7 +270,6 @@ function parseTimeIT_MVP(speech) { const t = normalizeText(speech); if (!t) return null; - // 20:30 let m = t.match(/\b(\d{1,2})[:\.](\d{2})\b/); if (m) { const hh = parseInt(m[1], 10); @@ -240,7 +280,6 @@ function parseTimeIT_MVP(speech) { return null; } - // "20 e 30" / "20 30" m = t.match(/\b(\d{1,2})\s*(?:e)?\s*(\d{1,2})\b/); if (m) { const hh = parseInt(m[1], 10); @@ -250,7 +289,6 @@ function parseTimeIT_MVP(speech) { } } - // digits "2030" const digits = t.replace(/[^\d]/g, ""); if (digits.length === 4) { const hh = parseInt(digits.slice(0, 2), 10); @@ -260,7 +298,6 @@ function parseTimeIT_MVP(speech) { } } - // "alle 20" -> 20:00 m = t.match(/\b(\d{1,2})\b/); if (m) { const hh = parseInt(m[1], 10); @@ -351,11 +388,9 @@ async function createBookingEvent({ const privateKey = `callsid:${callSid}`; - // Idempotenza: cerca se esiste già un evento con callsid: nella description const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, - // limitiamo ricerca alla giornata per velocità timeMin: `${dateISO}T00:00:00Z`, timeMax: `${dateISO}T23:59:59Z`, singleEvents: true, @@ -421,8 +456,8 @@ ${say("Ciao! Hai chiamato TuttiBrilli Enoteca.")} ${pause(1)} ${gatherSpeech({ action, - prompt: "Vuoi prenotare un tavolo, oppure ti servono informazioni?", - hints: "prenotare, prenotazione, tavolo, posti, informazioni, orari, indirizzo", + prompt: "Vuoi prenotare un tavolo, oppure ti servono informazioni? Se vuoi un operatore, dimmi: operatore.", + hints: "prenotare, prenotazione, tavolo, posti, informazioni, orari, indirizzo, operatore, umano", })} ${say("Scusami, non ti ho sentito. Riproviamo.")} ${redirect(`${BASE_URL}/voice`)} @@ -446,7 +481,8 @@ app.post("/voice/step", async (req, res) => { return res.type("text/xml").status(200).send(twiml(xml)); } - function failOrRetry({ prompt1, prompt2, exitPrompt }) { + // >>> Fallback/Retry aggiornato: se finisco i tentativi => inoltro a operatore (se configurato) + function failOrRetry({ prompt1, prompt2, exitPrompt, forwardOnFail = true }) { const n = incRetry(session); if (n === 1) { @@ -464,6 +500,13 @@ ${redirect(action)} `); } + // tentativi finiti: inoltro oppure chiudo + sessions.set(callSid, session); + if (forwardOnFail && canForwardToHuman()) { + sessions.delete(callSid); + return respond(forwardToHumanTwiml("fallback_troppi_tentativi")); + } + sessions.delete(callSid); return respond(` ${say(exitPrompt)} @@ -472,13 +515,24 @@ ${hangup()} } try { + // >>> Se chiede operatore in qualunque momento => inoltro immediato + if (speech && wantsHuman(speech)) { + sessions.delete(callSid); + return respond(forwardToHumanTwiml("richiesta_utente")); + } + + // >>> Confidenza bassa: se preferisci, puoi inoltrare SOLO dopo 1 retry. + // Qui lo gestiamo in modo soft: aumenta la probabilità di fallback. + const lowConfidence = Number.isFinite(confidence) && confidence > 0 && confidence < LOW_CONFIDENCE_THRESHOLD; + // No input / no speech if (!speech) { if (session.step === 1) { return failOrRetry({ - prompt1: "Dimmi pure: vuoi prenotare o informazioni?", - prompt2: "Puoi dire, per esempio: 'voglio prenotare un tavolo'.", + prompt1: "Dimmi pure: vuoi prenotare o informazioni? Oppure di' operatore.", + prompt2: "Puoi dire, per esempio: 'voglio prenotare un tavolo'. Oppure: 'operatore'.", exitPrompt: "Non riesco a sentirti bene. Se vuoi, scrivici su WhatsApp. A presto!", + forwardOnFail: true, }); } if (session.step === 2) { @@ -486,6 +540,7 @@ ${hangup()} prompt1: "Come ti chiami?", prompt2: "Dimmi il tuo nome, ad esempio: 'Mario Rossi'.", exitPrompt: "Ok. Se vuoi, scrivici su WhatsApp. A presto!", + forwardOnFail: true, }); } if (session.step === 3) { @@ -493,6 +548,7 @@ ${hangup()} prompt1: "Per che giorno vuoi prenotare?", prompt2: "Puoi dire 'domani' oppure '25 12'.", exitPrompt: "Non riesco a prendere la data. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", + forwardOnFail: true, }); } if (session.step === 4) { @@ -500,6 +556,7 @@ ${hangup()} prompt1: "A che ora preferisci?", prompt2: "Puoi dire '20 e 30' oppure '21'.", exitPrompt: "Non riesco a prendere l'orario. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", + forwardOnFail: true, }); } if (session.step === 5) { @@ -507,6 +564,7 @@ ${hangup()} prompt1: "Per quante persone?", prompt2: "Dimmi un numero, ad esempio 'quattro'.", exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", + forwardOnFail: true, }); } if (session.step === 6) { @@ -514,10 +572,17 @@ ${hangup()} prompt1: "A che numero WhatsApp vuoi ricevere la conferma? Dimmi il numero iniziando con più trentanove.", prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", + forwardOnFail: true, }); } } + // >>> Se confidenza è bassa e siamo già in retry, puoi inoltrare più velocemente + if (lowConfidence && session.retries >= 1 && canForwardToHuman()) { + sessions.delete(callSid); + return respond(forwardToHumanTwiml("bassa_confidenza_stt")); + } + // STEP 1: Intento if (session.step === 1) { if (looksLikeBooking(speech)) { @@ -543,9 +608,10 @@ ${hangup()} } return failOrRetry({ - prompt1: "Scusami, vuoi prenotare un tavolo o informazioni?", - prompt2: "Puoi dire: 'prenotare un tavolo' oppure 'informazioni'.", + prompt1: "Scusami, vuoi prenotare un tavolo o informazioni? Oppure di' operatore.", + prompt2: "Puoi dire: 'prenotare un tavolo' oppure 'informazioni'. Oppure: 'operatore'.", exitPrompt: "Va bene. Scrivici su WhatsApp e ti rispondiamo appena possibile. A presto!", + forwardOnFail: true, }); } @@ -572,6 +638,7 @@ ${redirect(action)} prompt1: "Non sono sicuro di aver capito la data. Per che giorno vuoi prenotare?", prompt2: "Puoi dire 'domani' oppure '25 12'.", exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", + forwardOnFail: true, }); } @@ -596,6 +663,7 @@ ${redirect(action)} prompt1: "Non sono sicuro di aver capito l'orario. A che ora preferisci?", prompt2: "Puoi dire '20 e 30' oppure '21'.", exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", + forwardOnFail: true, }); } @@ -620,6 +688,7 @@ ${redirect(action)} prompt1: "Quante persone sarete?", prompt2: "Dimmi un numero, ad esempio 'quattro'.", exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", + forwardOnFail: true, }); } @@ -642,8 +711,8 @@ ${say(`Perfetto. Ricapitolo: ${humanDateIT(session.dateISO)} alle ${session.time ${pause(1)} ${gatherSpeech({ action, - prompt: "Ti mando la conferma su WhatsApp a questo numero. Va bene?", - hints: "sì, si, va bene, ok, certo, no, cambia, un altro numero", + prompt: "Ti mando la conferma su WhatsApp a questo numero. Va bene? Se vuoi un operatore, dimmi operatore.", + hints: "sì, si, va bene, ok, certo, no, cambia, un altro numero, operatore, umano", })} ${redirect(action)} `); @@ -672,7 +741,7 @@ ${redirect(action)} const no = /\b(no|non va bene|cambia|altro numero)\b/.test(speech); if (yes && !no) { - session.waTo = `whatsapp:${session.fromCaller}`; // fromCaller già +39... + session.waTo = `whatsapp:${session.fromCaller}`; session.step = 7; session.substep = null; resetRetries(session); @@ -688,9 +757,10 @@ ${redirect(action)} `); } else { return failOrRetry({ - prompt1: "Scusami, ti va bene che invii il WhatsApp a questo numero? Puoi dire sì o no.", - prompt2: "Dimmi solo: sì, va bene. Oppure: no, un altro numero.", + prompt1: "Scusami, ti va bene che invii il WhatsApp a questo numero? Puoi dire sì o no. Oppure: operatore.", + prompt2: "Dimmi solo: sì, va bene. Oppure: no, un altro numero. Oppure: operatore.", exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", + forwardOnFail: true, }); } } @@ -702,6 +772,7 @@ ${redirect(action)} prompt1: "Non sono sicuro di aver capito il numero. Me lo ripeti iniziando con più trentanove?", prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", + forwardOnFail: true, }); } @@ -769,6 +840,12 @@ ${redirect(`${BASE_URL}/voice`)} } catch (err) { console.error("VOICE FLOW ERROR:", err); sessions.delete(callSid); + + // Se vuoi: anche sull'errore tecnico puoi inoltrare all'operatore + if (canForwardToHuman()) { + return res.type("text/xml").status(200).send(twiml(forwardToHumanTwiml("errore_tecnico"))); + } + return res.type("text/xml").status(200).send( twiml(` ${say("C'è stato un problema tecnico. Riprova tra poco.")} @@ -786,4 +863,5 @@ app.listen(PORT, () => { console.log(`BASE_URL: ${BASE_URL || "(non impostato)"}`); console.log(`Calendar configured: ${Boolean(GOOGLE_SERVICE_ACCOUNT_JSON && GOOGLE_CALENDAR_ID)}`); console.log(`Twilio configured: ${Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN)}`); + console.log(`Forwarding enabled: ${ENABLE_FORWARDING} | HUMAN_FORWARD_TO: ${HUMAN_FORWARD_TO || "(non impostato)"}`); }); From 50c23f3477c11b6b7cb2e6397cb2afdfca5d0ed9 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Wed, 24 Dec 2025 18:23:11 +0100 Subject: [PATCH 021/143] Update app.js --- app.js | 124 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 59 deletions(-) diff --git a/app.js b/app.js index 335c38d697..f7c754b55d 100644 --- a/app.js +++ b/app.js @@ -785,75 +785,81 @@ ${redirect(action)} } } - // STEP 7: crea evento su Calendar + invia WhatsApp - if (session.step === 7) { - // 1) Google Calendar - let calendarResult = null; - try { - calendarResult = await createBookingEvent({ - callSid, - name: session.name, - dateISO: session.dateISO, - time24: session.time24, - people: session.people, - phone: (session.fromCaller || "").startsWith("+") ? session.fromCaller : "", - waTo: session.waTo, - }); - } catch (e) { - console.error("Google Calendar insert failed:", e); - } - - // 2) WhatsApp - const waTo = session.waTo; - - const waBody = calendarResult - ? `✅ Prenotazione registrata\nNome: ${session.name}\nData: ${humanDateIT(session.dateISO)}\nOra: ${session.time24}\nPersone: ${session.people}\n\nSe devi modificare o annullare, rispondi a questo messaggio.` - : `✅ Richiesta ricevuta\nNome: ${session.name}\nData: ${humanDateIT(session.dateISO)}\nOra: ${session.time24}\nPersone: ${session.people}\n\nTi confermiamo a breve.`; - - if (!twilioClient) { - console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); - } else if (!TWILIO_WHATSAPP_FROM) { - console.error("Manca TWILIO_WHATSAPP_FROM (es. whatsapp:+14155238886)"); - } else if (!waTo || !hasValidWaAddress(waTo)) { - console.error("waTo non valido:", waTo); - } else { - await twilioClient.messages.create({ - from: TWILIO_WHATSAPP_FROM, - to: waTo, - body: waBody, - }); - } +// STEP 7: crea evento su Calendar + invia WhatsApp (SOLO SE CALENDAR OK) +if (session.step === 7) { + // 1) Google Calendar (DEVE andare a buon fine) + let calendarResult = null; - sessions.delete(callSid); - return respond(` -${say("Perfetto! Ho registrato la prenotazione e ti ho inviato un WhatsApp di conferma. A presto da TuttiBrilli!")} + try { + calendarResult = await createBookingEvent({ + callSid, + name: session.name, + dateISO: session.dateISO, + time24: session.time24, + people: session.people, + phone: (session.fromCaller || "").startsWith("+") ? session.fromCaller : "", + waTo: session.waTo, + }); + } catch (e) { + console.error("Google Calendar insert failed:", e); + + // Se Calendar fallisce: NON inviare WhatsApp, chiudi con messaggio + sessions.delete(callSid); + return respond(` +${say("Ho avuto un problema tecnico nel registrare la prenotazione in calendario. Riprova tra poco oppure scrivici su WhatsApp.")} ${hangup()} - `); - } + `); + } + + // Se per qualunque motivo calendarResult è nullo, trattalo come fallimento + if (!calendarResult) { + console.error("Google Calendar insert failed: calendarResult is null/undefined"); - // fallback finale sessions.delete(callSid); return respond(` -${say("Ripartiamo da capo.")} -${redirect(`${BASE_URL}/voice`)} +${say("Non sono riuscito a registrare la prenotazione in calendario. Riprova tra poco oppure scrivici su WhatsApp.")} +${hangup()} `); - } catch (err) { - console.error("VOICE FLOW ERROR:", err); - sessions.delete(callSid); + } - // Se vuoi: anche sull'errore tecnico puoi inoltrare all'operatore - if (canForwardToHuman()) { - return res.type("text/xml").status(200).send(twiml(forwardToHumanTwiml("errore_tecnico"))); + // 2) WhatsApp (SOLO DOPO Calendar OK) + const waTo = session.waTo; + + const waBody = + `✅ Prenotazione confermata\n` + + `Nome: ${session.name}\n` + + `Data: ${humanDateIT(session.dateISO)}\n` + + `Ora: ${session.time24}\n` + + `Persone: ${session.people}\n\n` + + `Se devi modificare o annullare, rispondi a questo messaggio.`; + + try { + if (!twilioClient) { + console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); + } else if (!TWILIO_WHATSAPP_FROM) { + console.error("Manca TWILIO_WHATSAPP_FROM (es. whatsapp:+1415...)"); + } else if (!waTo || !hasValidWaAddress(waTo)) { + console.error("waTo non valido:", waTo); + } else { + await twilioClient.messages.create({ + from: TWILIO_WHATSAPP_FROM, + to: waTo, + body: waBody, + }); } + } catch (e) { + console.error("WhatsApp send failed:", e); + // Nota: qui NON blocchiamo la prenotazione, perché è già su Calendar. + } - return res.type("text/xml").status(200).send( - twiml(` -${say("C'è stato un problema tecnico. Riprova tra poco.")} + // Fine flusso + sessions.delete(callSid); + return respond(` +${say("Perfetto! Ho registrato la prenotazione e ti ho inviato un WhatsApp di conferma. A presto da TuttiBrilli!")} ${hangup()} - `) - ); - } -}); + `); +} + // -------------------- // START SERVER From 24fff39f7ccf96fb885ca054d0c8cc95e883d902 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 27 Dec 2025 22:06:10 +0100 Subject: [PATCH 022/143] Update app.js --- app.js | 262 +++++++++++++++++++++++++++++++++------------------------ 1 file changed, 152 insertions(+), 110 deletions(-) diff --git a/app.js b/app.js index f7c754b55d..d35036daa7 100644 --- a/app.js +++ b/app.js @@ -15,14 +15,18 @@ const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ +// (OPZIONALE ma consigliato) per evitare problemi di JSON spezzato nelle ENV +// Metti qui il JSON del service account in Base64, se vuoi +const GOOGLE_SERVICE_ACCOUNT_JSON_B64 = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64 || ""; const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ""; + const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; // es: ...@group.calendar.google.com const DEFAULT_EVENT_DURATION_MINUTES = parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || "120", 10); -// >>> INOLTRO A OPERATORE (NUOVO) -const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; // es: +393331112222 +// Inoltro chiamata a operatore (opzionale) const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "true").toLowerCase() === "true"; -const LOW_CONFIDENCE_THRESHOLD = parseFloat(process.env.LOW_CONFIDENCE_THRESHOLD || "0.45"); // regola a piacere +const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; // es: +393425169640 +const LOW_CONFIDENCE_THRESHOLD = parseFloat(process.env.LOW_CONFIDENCE_THRESHOLD || "0.45"); // Twilio client let twilioClient = null; @@ -34,11 +38,26 @@ if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { // Google Calendar client (Service Account) const { google } = require("googleapis"); +function getServiceAccountJsonRaw() { + // preferisci B64 se presente + if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { + try { + return Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf8"); + } catch (e) { + console.error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 decode failed:", e); + return ""; + } + } + return GOOGLE_SERVICE_ACCOUNT_JSON; +} + function getCalendarClient() { - if (!GOOGLE_SERVICE_ACCOUNT_JSON) return null; if (!GOOGLE_CALENDAR_ID) return null; - const creds = JSON.parse(GOOGLE_SERVICE_ACCOUNT_JSON); + const raw = getServiceAccountJsonRaw(); + if (!raw) return null; + + const creds = JSON.parse(raw); const auth = new google.auth.JWT({ email: creds.client_email, @@ -110,30 +129,22 @@ function gatherSpeech({ `; } -// >>> DIAL/INOLTRO (NUOVO) -function dialNumber(numberE164, { callerId = null, timeout = 20 } = {}) { - // callerId: opzionale, ma spesso conviene lasciare quello di Twilio - const attrs = [ - timeout ? `timeout="${Number(timeout)}"` : "", - callerId ? `callerId="${xmlEscape(callerId)}"` : "", - ] - .filter(Boolean) - .join(" "); - return `${xmlEscape(numberE164)}`; +// Dial / forwarding +function dialNumber(numberE164, { timeout = 25 } = {}) { + return `${xmlEscape(numberE164)}`; } function wantsHuman(text) { const t = String(text || "").toLowerCase(); - return /\b(operatore|umano|persona|collega|parlare con qualcuno|assistenza)\b/.test(t); + return /\b(operatore|umano|persona|collega|assistenza|parlare con qualcuno)\b/.test(t); } function canForwardToHuman() { - return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && /^\+\d{8,15}$/.test(HUMAN_FORWARD_TO); + return ENABLE_FORWARDING && /^\+\d{8,15}$/.test(HUMAN_FORWARD_TO || ""); } -function forwardToHumanTwiml(reason = "richiesta assistenza") { +function forwardToHumanTwiml() { if (!canForwardToHuman()) { - // se non configurato, chiudiamo in modo elegante return ` ${say("Va bene. Al momento non riesco a trasferire la chiamata. Ti invito a scriverci su WhatsApp. A presto!")} ${hangup()} @@ -238,6 +249,7 @@ function parseDateIT_MVP(speech) { return toISODate(d); } + // formati tipo 25/12/2025 o 25-12-2025 let m = t.match(/\b(\d{1,2})[\/\-\.](\d{1,2})(?:[\/\-\.](\d{2,4}))?\b/); if (m) { let dd = parseInt(m[1], 10); @@ -252,6 +264,7 @@ function parseDateIT_MVP(speech) { return null; } + // "2512" o "25 12" const digits = t.replace(/[^\d]/g, ""); if (digits.length === 4) { const dd = parseInt(digits.slice(0, 2), 10); @@ -270,6 +283,7 @@ function parseTimeIT_MVP(speech) { const t = normalizeText(speech); if (!t) return null; + // 20:30 let m = t.match(/\b(\d{1,2})[:\.](\d{2})\b/); if (m) { const hh = parseInt(m[1], 10); @@ -280,6 +294,7 @@ function parseTimeIT_MVP(speech) { return null; } + // "20 e 30" / "20 30" m = t.match(/\b(\d{1,2})\s*(?:e)?\s*(\d{1,2})\b/); if (m) { const hh = parseInt(m[1], 10); @@ -289,6 +304,7 @@ function parseTimeIT_MVP(speech) { } } + // digits "2030" const digits = t.replace(/[^\d]/g, ""); if (digits.length === 4) { const hh = parseInt(digits.slice(0, 2), 10); @@ -298,6 +314,7 @@ function parseTimeIT_MVP(speech) { } } + // "alle 20" -> 20:00 m = t.match(/\b(\d{1,2})\b/); if (m) { const hh = parseInt(m[1], 10); @@ -384,10 +401,11 @@ async function createBookingEvent({ waTo, }) { const calendar = getCalendarClient(); - if (!calendar) throw new Error("Google Calendar non configurato (manca JSON o CALENDAR_ID)."); + if (!calendar) throw new Error("Google Calendar non configurato (manca JSON/JSON_B64 o CALENDAR_ID)."); const privateKey = `callsid:${callSid}`; + // Idempotenza: cerca se esiste già un evento con callsid: nella description const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -481,7 +499,6 @@ app.post("/voice/step", async (req, res) => { return res.type("text/xml").status(200).send(twiml(xml)); } - // >>> Fallback/Retry aggiornato: se finisco i tentativi => inoltro a operatore (se configurato) function failOrRetry({ prompt1, prompt2, exitPrompt, forwardOnFail = true }) { const n = incRetry(session); @@ -500,11 +517,11 @@ ${redirect(action)} `); } - // tentativi finiti: inoltro oppure chiudo sessions.set(callSid, session); + if (forwardOnFail && canForwardToHuman()) { sessions.delete(callSid); - return respond(forwardToHumanTwiml("fallback_troppi_tentativi")); + return respond(forwardToHumanTwiml()); } sessions.delete(callSid); @@ -515,15 +532,20 @@ ${hangup()} } try { - // >>> Se chiede operatore in qualunque momento => inoltro immediato + // Inoltro a umano se richiesto if (speech && wantsHuman(speech)) { sessions.delete(callSid); - return respond(forwardToHumanTwiml("richiesta_utente")); + return respond(forwardToHumanTwiml()); } - // >>> Confidenza bassa: se preferisci, puoi inoltrare SOLO dopo 1 retry. - // Qui lo gestiamo in modo soft: aumenta la probabilità di fallback. - const lowConfidence = Number.isFinite(confidence) && confidence > 0 && confidence < LOW_CONFIDENCE_THRESHOLD; + const lowConfidence = + Number.isFinite(confidence) && confidence > 0 && confidence < LOW_CONFIDENCE_THRESHOLD; + + // Se bassa confidenza e già in retry, possiamo inoltrare + if (lowConfidence && session.retries >= 1 && canForwardToHuman()) { + sessions.delete(callSid); + return respond(forwardToHumanTwiml()); + } // No input / no speech if (!speech) { @@ -532,7 +554,6 @@ ${hangup()} prompt1: "Dimmi pure: vuoi prenotare o informazioni? Oppure di' operatore.", prompt2: "Puoi dire, per esempio: 'voglio prenotare un tavolo'. Oppure: 'operatore'.", exitPrompt: "Non riesco a sentirti bene. Se vuoi, scrivici su WhatsApp. A presto!", - forwardOnFail: true, }); } if (session.step === 2) { @@ -540,7 +561,6 @@ ${hangup()} prompt1: "Come ti chiami?", prompt2: "Dimmi il tuo nome, ad esempio: 'Mario Rossi'.", exitPrompt: "Ok. Se vuoi, scrivici su WhatsApp. A presto!", - forwardOnFail: true, }); } if (session.step === 3) { @@ -548,7 +568,6 @@ ${hangup()} prompt1: "Per che giorno vuoi prenotare?", prompt2: "Puoi dire 'domani' oppure '25 12'.", exitPrompt: "Non riesco a prendere la data. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", - forwardOnFail: true, }); } if (session.step === 4) { @@ -556,7 +575,6 @@ ${hangup()} prompt1: "A che ora preferisci?", prompt2: "Puoi dire '20 e 30' oppure '21'.", exitPrompt: "Non riesco a prendere l'orario. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", - forwardOnFail: true, }); } if (session.step === 5) { @@ -564,7 +582,6 @@ ${hangup()} prompt1: "Per quante persone?", prompt2: "Dimmi un numero, ad esempio 'quattro'.", exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", - forwardOnFail: true, }); } if (session.step === 6) { @@ -572,17 +589,10 @@ ${hangup()} prompt1: "A che numero WhatsApp vuoi ricevere la conferma? Dimmi il numero iniziando con più trentanove.", prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", - forwardOnFail: true, }); } } - // >>> Se confidenza è bassa e siamo già in retry, puoi inoltrare più velocemente - if (lowConfidence && session.retries >= 1 && canForwardToHuman()) { - sessions.delete(callSid); - return respond(forwardToHumanTwiml("bassa_confidenza_stt")); - } - // STEP 1: Intento if (session.step === 1) { if (looksLikeBooking(speech)) { @@ -611,7 +621,6 @@ ${hangup()} prompt1: "Scusami, vuoi prenotare un tavolo o informazioni? Oppure di' operatore.", prompt2: "Puoi dire: 'prenotare un tavolo' oppure 'informazioni'. Oppure: 'operatore'.", exitPrompt: "Va bene. Scrivici su WhatsApp e ti rispondiamo appena possibile. A presto!", - forwardOnFail: true, }); } @@ -638,7 +647,6 @@ ${redirect(action)} prompt1: "Non sono sicuro di aver capito la data. Per che giorno vuoi prenotare?", prompt2: "Puoi dire 'domani' oppure '25 12'.", exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", - forwardOnFail: true, }); } @@ -663,7 +671,6 @@ ${redirect(action)} prompt1: "Non sono sicuro di aver capito l'orario. A che ora preferisci?", prompt2: "Puoi dire '20 e 30' oppure '21'.", exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", - forwardOnFail: true, }); } @@ -688,7 +695,6 @@ ${redirect(action)} prompt1: "Quante persone sarete?", prompt2: "Dimmi un numero, ad esempio 'quattro'.", exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", - forwardOnFail: true, }); } @@ -760,7 +766,6 @@ ${redirect(action)} prompt1: "Scusami, ti va bene che invii il WhatsApp a questo numero? Puoi dire sì o no. Oppure: operatore.", prompt2: "Dimmi solo: sì, va bene. Oppure: no, un altro numero. Oppure: operatore.", exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", - forwardOnFail: true, }); } } @@ -772,7 +777,6 @@ ${redirect(action)} prompt1: "Non sono sicuro di aver capito il numero. Me lo ripeti iniziando con più trentanove?", prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", - forwardOnFail: true, }); } @@ -785,81 +789,107 @@ ${redirect(action)} } } -// STEP 7: crea evento su Calendar + invia WhatsApp (SOLO SE CALENDAR OK) -if (session.step === 7) { - // 1) Google Calendar (DEVE andare a buon fine) - let calendarResult = null; + // STEP 7: crea evento su Calendar + invia WhatsApp (SOLO SE CALENDAR OK) + if (session.step === 7) { + // 1) Google Calendar (DEVE andare a buon fine) + let calendarResult = null; + + try { + calendarResult = await createBookingEvent({ + callSid, + name: session.name, + dateISO: session.dateISO, + time24: session.time24, + people: session.people, + phone: (session.fromCaller || "").startsWith("+") ? session.fromCaller : "", + waTo: session.waTo, + }); - try { - calendarResult = await createBookingEvent({ - callSid, - name: session.name, - dateISO: session.dateISO, - time24: session.time24, - people: session.people, - phone: (session.fromCaller || "").startsWith("+") ? session.fromCaller : "", - waTo: session.waTo, - }); - } catch (e) { - console.error("Google Calendar insert failed:", e); - - // Se Calendar fallisce: NON inviare WhatsApp, chiudi con messaggio - sessions.delete(callSid); - return respond(` + // Log utile per debug + console.log("CALENDAR OK:", { + reused: calendarResult.reused, + eventId: calendarResult.eventId, + htmlLink: calendarResult.htmlLink, + }); + } catch (e) { + console.error("Google Calendar insert failed:", e); + + sessions.delete(callSid); + return respond(` ${say("Ho avuto un problema tecnico nel registrare la prenotazione in calendario. Riprova tra poco oppure scrivici su WhatsApp.")} ${hangup()} - `); - } + `); + } - // Se per qualunque motivo calendarResult è nullo, trattalo come fallimento - if (!calendarResult) { - console.error("Google Calendar insert failed: calendarResult is null/undefined"); + if (!calendarResult) { + console.error("Google Calendar insert failed: calendarResult is null/undefined"); - sessions.delete(callSid); - return respond(` + sessions.delete(callSid); + return respond(` ${say("Non sono riuscito a registrare la prenotazione in calendario. Riprova tra poco oppure scrivici su WhatsApp.")} ${hangup()} - `); - } + `); + } - // 2) WhatsApp (SOLO DOPO Calendar OK) - const waTo = session.waTo; + // 2) WhatsApp (SOLO DOPO Calendar OK) + const waTo = session.waTo; + const waBody = + `✅ Prenotazione confermata\n` + + `Nome: ${session.name}\n` + + `Data: ${humanDateIT(session.dateISO)}\n` + + `Ora: ${session.time24}\n` + + `Persone: ${session.people}\n\n` + + `Se devi modificare o annullare, rispondi a questo messaggio.`; + + try { + if (!twilioClient) { + console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); + } else if (!TWILIO_WHATSAPP_FROM || !/^whatsapp:\+\d{8,15}$/.test(TWILIO_WHATSAPP_FROM)) { + console.error("TWILIO_WHATSAPP_FROM non valido:", TWILIO_WHATSAPP_FROM); + } else if (!waTo || !hasValidWaAddress(waTo)) { + console.error("waTo non valido:", waTo); + } else { + await twilioClient.messages.create({ + from: TWILIO_WHATSAPP_FROM, + to: waTo, + body: waBody, + }); + } + } catch (e) { + console.error("WhatsApp send failed:", e); + // Non blocchiamo: l'evento è già su Calendar + } - const waBody = - `✅ Prenotazione confermata\n` + - `Nome: ${session.name}\n` + - `Data: ${humanDateIT(session.dateISO)}\n` + - `Ora: ${session.time24}\n` + - `Persone: ${session.people}\n\n` + - `Se devi modificare o annullare, rispondi a questo messaggio.`; + sessions.delete(callSid); + return respond(` +${say("Perfetto! Ho registrato la prenotazione e ti ho inviato un WhatsApp di conferma. A presto da TuttiBrilli!")} +${hangup()} + `); + } - try { - if (!twilioClient) { - console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); - } else if (!TWILIO_WHATSAPP_FROM) { - console.error("Manca TWILIO_WHATSAPP_FROM (es. whatsapp:+1415...)"); - } else if (!waTo || !hasValidWaAddress(waTo)) { - console.error("waTo non valido:", waTo); - } else { - await twilioClient.messages.create({ - from: TWILIO_WHATSAPP_FROM, - to: waTo, - body: waBody, - }); + // fallback finale + sessions.delete(callSid); + return respond(` +${say("Ripartiamo da capo.")} +${redirect(`${BASE_URL}/voice`)} + `); + } catch (err) { + console.error("VOICE FLOW ERROR:", err); + sessions.delete(callSid); + + // Se vuoi, su errore tecnico inoltra a operatore (se configurato) + if (canForwardToHuman()) { + return res.type("text/xml").status(200).send(twiml(forwardToHumanTwiml())); } - } catch (e) { - console.error("WhatsApp send failed:", e); - // Nota: qui NON blocchiamo la prenotazione, perché è già su Calendar. - } - // Fine flusso - sessions.delete(callSid); - return respond(` -${say("Perfetto! Ho registrato la prenotazione e ti ho inviato un WhatsApp di conferma. A presto da TuttiBrilli!")} + return res.type("text/xml").status(200).send( + twiml(` +${say("C'è stato un problema tecnico. Riprova tra poco.")} ${hangup()} - `); -} - + `) + ); + } +}); // -------------------- // START SERVER @@ -867,7 +897,19 @@ ${hangup()} app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`BASE_URL: ${BASE_URL || "(non impostato)"}`); - console.log(`Calendar configured: ${Boolean(GOOGLE_SERVICE_ACCOUNT_JSON && GOOGLE_CALENDAR_ID)}`); + + const raw = getServiceAccountJsonRaw(); + console.log(`Calendar configured: ${Boolean(raw && GOOGLE_CALENDAR_ID)}`); console.log(`Twilio configured: ${Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN)}`); - console.log(`Forwarding enabled: ${ENABLE_FORWARDING} | HUMAN_FORWARD_TO: ${HUMAN_FORWARD_TO || "(non impostato)"}`); + + console.log( + `Forwarding enabled: ${ENABLE_FORWARDING} | HUMAN_FORWARD_TO: ${HUMAN_FORWARD_TO || "(non impostato)"}` + ); + + if (raw && !GOOGLE_SERVICE_ACCOUNT_JSON_B64) { + console.log(`GOOGLE_SERVICE_ACCOUNT_JSON length: ${(GOOGLE_SERVICE_ACCOUNT_JSON || "").length}`); + } + if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { + console.log(`GOOGLE_SERVICE_ACCOUNT_JSON_B64 length: ${GOOGLE_SERVICE_ACCOUNT_JSON_B64.length}`); + } }); From 0cac0c03987e3320dfc5e2b28f08a5ae6aca5788 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 27 Dec 2025 23:41:05 +0100 Subject: [PATCH 023/143] Implement debug endpoint for calendar-test Added a debug endpoint for testing calendar bookings. --- app.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app.js b/app.js index d35036daa7..8547e07338 100644 --- a/app.js +++ b/app.js @@ -890,6 +890,41 @@ ${hangup()} ); } }); +// ==================== +// DEBUG CALENDAR TEST +// ==================== +app.get("/debug/calendar-test", async (req, res) => { + try { + console.log("[DEBUG] calendar-test called"); + + const dateISO = req.query.dateISO || new Date().toISOString().slice(0, 10); + const timeHHMM = req.query.timeHHMM || "20:30"; + + const result = await createBookingEvent({ + name: "TEST TuttiBrilli", + phone: "+391234567890", + dateISO, + timeHHMM, + partySize: 2, + notes: "Evento di test creato da /debug/calendar-test", + }); + + if (result.ok) { + return res.json({ ok: true, eventId: result.eventId }); + } + + return res.status(500).json({ + ok: false, + error: result.error || "Calendar insert failed", + }); + } catch (err) { + console.error("[DEBUG] calendar-test error:", err); + return res.status(500).json({ + ok: false, + message: err.message, + }); + } +}); // -------------------- // START SERVER From cf4002c3f7437aaf9f40068788f775a6a16937a8 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 00:13:58 +0100 Subject: [PATCH 024/143] Update app.js --- app.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/app.js b/app.js index 8547e07338..e011261922 100644 --- a/app.js +++ b/app.js @@ -400,6 +400,55 @@ async function createBookingEvent({ phone, waTo, }) { + // ============================ + // NORMALIZZAZIONE INPUT + GUARDRAILS (ANTI 400) + // ============================ + // Il debug endpoint a volte passa nomi diversi (partySize/timeHHMM). + // Qui li normalizziamo in "people" e "time24" per evitare undefined. + if (people == null && typeof partySize !== "undefined") people = partySize; + if (!time24 && typeof timeHHMM !== "undefined") time24 = timeHHMM; + + // Validazioni minime (fallisci presto con errore chiaro) + if (!name) throw new Error("createBookingEvent: name mancante"); + if (!dateISO || !/^\d{4}-\d{2}-\d{2}$/.test(dateISO)) { + throw new Error(`createBookingEvent: dateISO non valido: ${dateISO}`); + } + if (!time24 || !/^\d{2}:\d{2}$/.test(time24)) { + throw new Error(`createBookingEvent: time24 non valido: ${time24}`); + } + const peopleNum = Number(people); + if (!Number.isFinite(peopleNum) || peopleNum <= 0) { + throw new Error(`createBookingEvent: people non valido: ${people}`); + } + + const tz = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; + + // Costruzione start/end robusta + const startDate = new Date(`${dateISO}T${time24}:00`); + if (Number.isNaN(startDate.getTime())) { + throw new Error(`createBookingEvent: startDate invalida: ${dateISO} ${time24}`); + } + const endDate = new Date(startDate.getTime() + 90 * 60 * 1000); + + // Event payload (Google Calendar RFC3339 via toISOString) + const event = { + summary: `TuttiBrilli - ${name} - ${peopleNum} pax`, + description: [ + "Prenotazione", + `Nome: ${name}`, + `Persone: ${peopleNum}`, + `Telefono: ${phone || "-"}`, + `WhatsApp: ${waTo || "-"}`, + `callSid:${callSid || "-"}`, + ].join("\n"), + start: { dateTime: startDate.toISOString(), timeZone: tz }, + end: { dateTime: endDate.toISOString(), timeZone: tz }, + }; + + console.log("[CALENDAR] event payload:", JSON.stringify(event, null, 2)); + // ============================ + // FINE NORMALIZZAZIONE INPUT + // ============================ const calendar = getCalendarClient(); if (!calendar) throw new Error("Google Calendar non configurato (manca JSON/JSON_B64 o CALENDAR_ID)."); From f208f8bef721bd96023cfaed284f019881b3a7e2 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 00:21:35 +0100 Subject: [PATCH 025/143] Update app.js --- app.js | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/app.js b/app.js index e011261922..b5051b530e 100644 --- a/app.js +++ b/app.js @@ -469,23 +469,41 @@ async function createBookingEvent({ return { eventId: found.id, htmlLink: found.htmlLink, reused: true }; } - const startDateTime = `${dateISO}T${time24}:00`; + // ============================ + // START/END RFC3339 ROBUSTI (anti 400) + // ============================ + const tz = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; + + // Costruisci Date in modo sicuro e poi usa toISOString (RFC3339 valido) const start = new Date(`${dateISO}T${time24}:00`); + if (Number.isNaN(start.getTime())) { + throw new Error(`createBookingEvent: start invalido: ${dateISO} ${time24}`); + } + const end = addMinutes(start, DEFAULT_EVENT_DURATION_MINUTES); - const endParts = toLocalDateTimeParts(end); - const endDateTime = `${endParts.date}T${endParts.time}:00`; + if (Number.isNaN(end.getTime())) { + throw new Error(`createBookingEvent: end invalido da start: ${start.toISOString()}`); + } + const peopleNum = Number(people); const requestBody = { - summary: `TuttiBrilli – ${name} – ${people} pax`, - description: - `Prenotazione\n` + - `Nome: ${name}\n` + - `Persone: ${people}\n` + - `Telefono: ${phone || "-"}\n` + - `WhatsApp: ${waTo || "-"}\n` + - `${privateKey}\n`, - start: { dateTime: startDateTime, timeZone: "Europe/Rome" }, - end: { dateTime: endDateTime, timeZone: "Europe/Rome" }, + summary: `TuttiBrilli - ${name} - ${peopleNum} pax`, + description: [ + "Prenotazione", + `Nome: ${name}`, + `Persone: ${peopleNum}`, + `Telefono: ${phone || "-"}`, + `WhatsApp: ${waTo || "-"}`, + `callSid:${callSid || "-"}`, + ].join("\n"), + start: { dateTime: start.toISOString(), timeZone: tz }, + end: { dateTime: end.toISOString(), timeZone: tz }, + }; + + console.log("[CALENDAR] requestBody:", JSON.stringify(requestBody, null, 2)); + // ============================ + // FINE START/END ROBUSTI + // ============================ }; const resp = await calendar.events.insert({ From 28bc90f68fef3c3eb3ec50b281215bb1df5dd5fb Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 00:24:51 +0100 Subject: [PATCH 026/143] Remove booking event validation and payload construction --- app.js | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/app.js b/app.js index b5051b530e..64fc3d5671 100644 --- a/app.js +++ b/app.js @@ -415,38 +415,7 @@ async function createBookingEvent({ } if (!time24 || !/^\d{2}:\d{2}$/.test(time24)) { throw new Error(`createBookingEvent: time24 non valido: ${time24}`); - } - const peopleNum = Number(people); - if (!Number.isFinite(peopleNum) || peopleNum <= 0) { - throw new Error(`createBookingEvent: people non valido: ${people}`); - } - - const tz = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; - - // Costruzione start/end robusta - const startDate = new Date(`${dateISO}T${time24}:00`); - if (Number.isNaN(startDate.getTime())) { - throw new Error(`createBookingEvent: startDate invalida: ${dateISO} ${time24}`); - } - const endDate = new Date(startDate.getTime() + 90 * 60 * 1000); - - // Event payload (Google Calendar RFC3339 via toISOString) - const event = { - summary: `TuttiBrilli - ${name} - ${peopleNum} pax`, - description: [ - "Prenotazione", - `Nome: ${name}`, - `Persone: ${peopleNum}`, - `Telefono: ${phone || "-"}`, - `WhatsApp: ${waTo || "-"}`, - `callSid:${callSid || "-"}`, - ].join("\n"), - start: { dateTime: startDate.toISOString(), timeZone: tz }, - end: { dateTime: endDate.toISOString(), timeZone: tz }, - }; - - console.log("[CALENDAR] event payload:", JSON.stringify(event, null, 2)); - // ============================ + // ============================ // FINE NORMALIZZAZIONE INPUT // ============================ const calendar = getCalendarClient(); From 9b7343d6bb899fdeda65752f94102846cbf2f011 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 00:45:01 +0100 Subject: [PATCH 027/143] Update app.js --- app.js | 865 ++++++++++++++++++++------------------------------------- 1 file changed, 308 insertions(+), 557 deletions(-) diff --git a/app.js b/app.js index 64fc3d5671..fb4ab8b86d 100644 --- a/app.js +++ b/app.js @@ -16,7 +16,7 @@ const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ // (OPZIONALE ma consigliato) per evitare problemi di JSON spezzato nelle ENV -// Metti qui il JSON del service account in Base64, se vuoi +// Metti qui il JSON del service account in Base64 const GOOGLE_SERVICE_ACCOUNT_JSON_B64 = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64 || ""; const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ""; @@ -24,27 +24,32 @@ const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; // es: ...@grou const DEFAULT_EVENT_DURATION_MINUTES = parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || "120", 10); // Inoltro chiamata a operatore (opzionale) -const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "true").toLowerCase() === "true"; -const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; // es: +393425169640 -const LOW_CONFIDENCE_THRESHOLD = parseFloat(process.env.LOW_CONFIDENCE_THRESHOLD || "0.45"); - -// Twilio client -let twilioClient = null; -if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) { - const twilio = require("twilio"); - twilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); -} +const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "false").toLowerCase() === "true"; +const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; // es: +39333... + +// OpenAI key presente nel tuo progetto ma qui non è usata direttamente +const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; -// Google Calendar client (Service Account) +// Lib +const twilio = require("twilio"); const { google } = require("googleapis"); +// Clients +const twilioClient = + TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN + ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) + : null; + +// -------------------- +// Google Calendar helpers +// -------------------- function getServiceAccountJsonRaw() { // preferisci B64 se presente if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { try { return Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf8"); } catch (e) { - console.error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 decode failed:", e); + console.error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 decode failed:", e?.message || e); return ""; } } @@ -52,12 +57,32 @@ function getServiceAccountJsonRaw() { } function getCalendarClient() { - if (!GOOGLE_CALENDAR_ID) return null; + if (!GOOGLE_CALENDAR_ID) { + console.error("[CALENDAR] Missing GOOGLE_CALENDAR_ID"); + return null; + } const raw = getServiceAccountJsonRaw(); - if (!raw) return null; + if (!raw) { + console.error( + "[CALENDAR] Missing service account JSON (GOOGLE_SERVICE_ACCOUNT_JSON or GOOGLE_SERVICE_ACCOUNT_JSON_B64)" + ); + return null; + } + + let creds; + try { + creds = JSON.parse(raw); + } catch (e) { + console.error("[CALENDAR] Service account JSON parse failed:", e?.message || e); + console.error("[CALENDAR] raw length:", raw.length); + return null; + } - const creds = JSON.parse(raw); + if (!creds?.client_email || !creds?.private_key) { + console.error("[CALENDAR] Invalid service account JSON: missing client_email/private_key"); + return null; + } const auth = new google.auth.JWT({ email: creds.client_email, @@ -88,7 +113,7 @@ function xmlEscape(s) { } function twiml(xmlInsideResponseTag) { - return `\n\n${xmlInsideResponseTag}\n`; + return `${xmlInsideResponseTag}`; } function say(text) { @@ -118,45 +143,14 @@ function gatherSpeech({ hints = "", }) { const safeAction = xmlEscape(action); - const safeHints = hints ? ` hints="${xmlEscape(hints)}"` : ""; - return ` - - ${say(prompt)} -`; -} - -// Dial / forwarding -function dialNumber(numberE164, { timeout = 25 } = {}) { - return `${xmlEscape(numberE164)}`; -} - -function wantsHuman(text) { - const t = String(text || "").toLowerCase(); - return /\b(operatore|umano|persona|collega|assistenza|parlare con qualcuno)\b/.test(t); -} - -function canForwardToHuman() { - return ENABLE_FORWARDING && /^\+\d{8,15}$/.test(HUMAN_FORWARD_TO || ""); -} + const safePrompt = xmlEscape(prompt || ""); + const safeHints = xmlEscape(hints || ""); -function forwardToHumanTwiml() { - if (!canForwardToHuman()) { - return ` -${say("Va bene. Al momento non riesco a trasferire la chiamata. Ti invito a scriverci su WhatsApp. A presto!")} -${hangup()} - `; - } return ` -${say("Perfetto, ti passo subito un operatore.")} -${pause(1)} -${dialNumber(HUMAN_FORWARD_TO, { timeout: 25 })} -${say("Non sono riuscito a metterti in contatto. Se vuoi, scrivici su WhatsApp. A presto!")} -${hangup()} - `; + + ${safePrompt} + +`; } // -------------------- @@ -174,57 +168,45 @@ ${hangup()} * time24: "HH:MM"|null, * people: number|null, * waTo: "whatsapp:+39..."|null, - * fromCaller: "+39..."|null + * phone: string|null, * } */ -const sessions = new Map(); // key: CallSid +const sessions = new Map(); function getSession(callSid) { - const s = sessions.get(callSid); - if (s) return s; - const fresh = { - step: 1, - substep: null, - retries: 0, - intent: null, - name: null, - dateISO: null, - time24: null, - people: null, - waTo: null, - fromCaller: null, - }; - sessions.set(callSid, fresh); - return fresh; + if (!sessions.has(callSid)) { + sessions.set(callSid, { + step: 1, + substep: null, + retries: 0, + intent: null, + name: null, + dateISO: null, + time24: null, + people: null, + waTo: null, + phone: null, + }); + } + return sessions.get(callSid); } function resetRetries(session) { session.retries = 0; } -function incRetry(session) { +function incRetries(session) { session.retries = (session.retries || 0) + 1; return session.retries; } -// -------------------- -// Parsing pragmatico (MVP) -// -------------------- -function normalizeText(t) { - return String(t || "") +function normalizeText(s) { + return String(s || "") .trim() .toLowerCase() .replace(/\s+/g, " "); } -function looksLikeBooking(text) { - return /prenot|tavol|posti|riserv/.test(text); -} - -function looksLikeInfo(text) { - return /info|orari|indirizz|dove|menu|menù|carta|vini|evento|serata/.test(text); -} - function nowLocal() { return new Date(); } @@ -237,88 +219,47 @@ function toISODate(d) { } function parseDateIT_MVP(speech) { + // MVP: accetta "oggi", "domani" o "YYYY-MM-DD" o "27 dicembre" etc. const t = normalizeText(speech); - if (!t) return null; - - const now = nowLocal(); - if (/\b(oggi|stasera)\b/.test(t)) return toISODate(now); - if (/\bdomani\b/.test(t)) { - const d = new Date(now.getTime()); - d.setDate(d.getDate() + 1); + const today = nowLocal(); + if (t.includes("oggi")) return toISODate(today); + if (t.includes("domani")) { + const d = new Date(today.getTime() + 24 * 60 * 60 * 1000); return toISODate(d); } - // formati tipo 25/12/2025 o 25-12-2025 - let m = t.match(/\b(\d{1,2})[\/\-\.](\d{1,2})(?:[\/\-\.](\d{2,4}))?\b/); - if (m) { - let dd = parseInt(m[1], 10); - let mm = parseInt(m[2], 10); - let yy = m[3] ? parseInt(m[3], 10) : now.getFullYear(); - if (yy < 100) yy = 2000 + yy; - - if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { - const d = new Date(yy, mm - 1, dd); - if (d.getFullYear() === yy && d.getMonth() === mm - 1 && d.getDate() === dd) return toISODate(d); - } - return null; - } - - // "2512" o "25 12" - const digits = t.replace(/[^\d]/g, ""); - if (digits.length === 4) { - const dd = parseInt(digits.slice(0, 2), 10); - const mm = parseInt(digits.slice(2, 4), 10); - const yy = now.getFullYear(); - if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { - const d = new Date(yy, mm - 1, dd); - if (d.getMonth() === mm - 1 && d.getDate() === dd) return toISODate(d); - } + // match YYYY-MM-DD + const iso = t.match(/(\d{4})-(\d{2})-(\d{2})/); + if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; + + // match dd/mm/yyyy + const dmY = t.match(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/); + if (dmY) { + const dd = String(dmY[1]).padStart(2, "0"); + const mm = String(dmY[2]).padStart(2, "0"); + const yyyy = dmY[3]; + return `${yyyy}-${mm}-${dd}`; } return null; } function parseTimeIT_MVP(speech) { + // MVP: accetta "20 30", "20:30", "alle 8 e mezza" const t = normalizeText(speech); - if (!t) return null; - - // 20:30 - let m = t.match(/\b(\d{1,2})[:\.](\d{2})\b/); - if (m) { - const hh = parseInt(m[1], 10); - const mm = parseInt(m[2], 10); - if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { - return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; - } - return null; - } - // "20 e 30" / "20 30" - m = t.match(/\b(\d{1,2})\s*(?:e)?\s*(\d{1,2})\b/); - if (m) { - const hh = parseInt(m[1], 10); - const mm = parseInt(m[2], 10); - if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { - return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; - } - } - - // digits "2030" - const digits = t.replace(/[^\d]/g, ""); - if (digits.length === 4) { - const hh = parseInt(digits.slice(0, 2), 10); - const mm = parseInt(digits.slice(2, 4), 10); - if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { - return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; - } + const hm = t.match(/(\d{1,2})[:\s](\d{2})/); + if (hm) { + const hh = String(hm[1]).padStart(2, "0"); + const mm = String(hm[2]).padStart(2, "0"); + return `${hh}:${mm}`; } - // "alle 20" -> 20:00 - m = t.match(/\b(\d{1,2})\b/); - if (m) { - const hh = parseInt(m[1], 10); - if (hh >= 0 && hh <= 23) return `${String(hh).padStart(2, "0")}:00`; + const onlyH = t.match(/\b(\d{1,2})\b/); + if (onlyH) { + const hh = String(onlyH[1]).padStart(2, "0"); + return `${hh}:00`; } return null; @@ -326,71 +267,39 @@ function parseTimeIT_MVP(speech) { function parsePeopleIT_MVP(speech) { const t = normalizeText(speech); - if (!t) return null; - const m = t.match(/\b(\d{1,2})\b/); - if (m) { - const n = parseInt(m[1], 10); - if (n >= 1 && n <= 20) return n; - } - - const map = { - uno: 1, una: 1, - due: 2, - tre: 3, - quattro: 4, - cinque: 5, - sei: 6, - sette: 7, - otto: 8, - nove: 9, - dieci: 10, - }; - for (const [k, v] of Object.entries(map)) { - if (new RegExp(`\\b${k}\\b`).test(t)) return v; - } + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + return n; +} - return null; +function hasValidWaAddress(s) { + return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); } -function humanDateIT(iso) { - if (!iso) return ""; - const [y, m, d] = iso.split("-"); - return `${d}/${m}/${y}`; +function isValidPhoneE164(s) { + return /^\+\d{8,15}$/.test(String(s || "").trim()); } -function normalizeWhatsappFromVoice(speechOrDigits) { - const raw = String(speechOrDigits || "").replace(/[^\d]/g, ""); - if (!raw) return ""; - if (raw.startsWith("39")) return `whatsapp:+${raw}`; - if (raw.startsWith("3")) return `whatsapp:+39${raw}`; - return `whatsapp:+${raw}`; +function addMinutes(date, minutes) { + return new Date(date.getTime() + minutes * 60 * 1000); } -function isLikelyItalianMobileE164(e164) { - return /^\+39\d{9,12}$/.test(e164 || ""); +function canForwardToHuman() { + return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && isValidPhoneE164(HUMAN_FORWARD_TO); } -function hasValidWaAddress(wa) { - return /^whatsapp:\+\d{8,15}$/.test(wa || ""); +function forwardToHumanTwiml() { + return ` + ${say("Ti metto in contatto con un operatore.")} + ${xmlEscape(HUMAN_FORWARD_TO)} + `; } // -------------------- -// Google Calendar: crea evento prenotazione (con idempotenza su CallSid) +// Google Calendar booking // -------------------- -function addMinutes(date, minutes) { - return new Date(date.getTime() + minutes * 60 * 1000); -} - -function toLocalDateTimeParts(date) { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - const hh = String(date.getHours()).padStart(2, "0"); - const mm = String(date.getMinutes()).padStart(2, "0"); - return { date: `${y}-${m}-${d}`, time: `${hh}:${mm}` }; -} - async function createBookingEvent({ callSid, name, @@ -399,52 +308,65 @@ async function createBookingEvent({ people, phone, waTo, + + // accettiamo anche i nomi del debug endpoint + timeHHMM, + partySize, + notes, // opzionale }) { - // ============================ - // NORMALIZZAZIONE INPUT + GUARDRAILS (ANTI 400) // ============================ - // Il debug endpoint a volte passa nomi diversi (partySize/timeHHMM). - // Qui li normalizziamo in "people" e "time24" per evitare undefined. - if (people == null && typeof partySize !== "undefined") people = partySize; - if (!time24 && typeof timeHHMM !== "undefined") time24 = timeHHMM; + // NORMALIZZAZIONE INPUT + GUARDRAILS + // ============================ + if (people == null && partySize != null) people = partySize; + if (!time24 && timeHHMM) time24 = timeHHMM; - // Validazioni minime (fallisci presto con errore chiaro) if (!name) throw new Error("createBookingEvent: name mancante"); if (!dateISO || !/^\d{4}-\d{2}-\d{2}$/.test(dateISO)) { throw new Error(`createBookingEvent: dateISO non valido: ${dateISO}`); } if (!time24 || !/^\d{2}:\d{2}$/.test(time24)) { throw new Error(`createBookingEvent: time24 non valido: ${time24}`); - // ============================ - // FINE NORMALIZZAZIONE INPUT - // ============================ + } + + const peopleNum = Number(people); + if (!Number.isFinite(peopleNum) || peopleNum <= 0) { + throw new Error(`createBookingEvent: people non valido: ${people}`); + } + const calendar = getCalendarClient(); - if (!calendar) throw new Error("Google Calendar non configurato (manca JSON/JSON_B64 o CALENDAR_ID)."); + if (!calendar) { + throw new Error("Google Calendar non configurato (manca JSON/JSON_B64 o CALENDAR_ID)."); + } - const privateKey = `callsid:${callSid}`; + const privateKey = `callsid:${callSid || "no-callsid"}`; - // Idempotenza: cerca se esiste già un evento con callsid: nella description + // ============================ + // IDEMPOTENZA: evita doppioni su retry Twilio + // ============================ const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, timeMin: `${dateISO}T00:00:00Z`, timeMax: `${dateISO}T23:59:59Z`, singleEvents: true, - maxResults: 5, + maxResults: 10, }); - const found = (existing.data.items || []).find((ev) => (ev.description || "").includes(privateKey)); + const found = (existing.data.items || []).find((ev) => + String(ev.description || "").includes(privateKey) + ); + if (found) { return { eventId: found.id, htmlLink: found.htmlLink, reused: true }; } - // ============================ + // ============================ // START/END RFC3339 ROBUSTI (anti 400) // ============================ const tz = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; - // Costruisci Date in modo sicuro e poi usa toISOString (RFC3339 valido) - const start = new Date(`${dateISO}T${time24}:00`); + // NOTA: aggiungiamo "Z" per evitare ambiguità timezone sul server (Render) + const start = new Date(`${dateISO}T${time24}:00Z`); if (Number.isNaN(start.getTime())) { throw new Error(`createBookingEvent: start invalido: ${dateISO} ${time24}`); } @@ -454,7 +376,6 @@ async function createBookingEvent({ throw new Error(`createBookingEvent: end invalido da start: ${start.toISOString()}`); } - const peopleNum = Number(people); const requestBody = { summary: `TuttiBrilli - ${name} - ${peopleNum} pax`, description: [ @@ -463,227 +384,103 @@ async function createBookingEvent({ `Persone: ${peopleNum}`, `Telefono: ${phone || "-"}`, `WhatsApp: ${waTo || "-"}`, - `callSid:${callSid || "-"}`, - ].join("\n"), + notes ? `Note: ${notes}` : null, + privateKey, + ] + .filter(Boolean) + .join("\n"), start: { dateTime: start.toISOString(), timeZone: tz }, end: { dateTime: end.toISOString(), timeZone: tz }, }; console.log("[CALENDAR] requestBody:", JSON.stringify(requestBody, null, 2)); - // ============================ - // FINE START/END ROBUSTI - // ============================ - }; - const resp = await calendar.events.insert({ - calendarId: GOOGLE_CALENDAR_ID, - requestBody, - }); + try { + const resp = await calendar.events.insert({ + calendarId: GOOGLE_CALENDAR_ID, + requestBody, + }); - return { eventId: resp.data.id, htmlLink: resp.data.htmlLink, reused: false }; + return { eventId: resp.data.id, htmlLink: resp.data.htmlLink, reused: false }; + } catch (err) { + console.error("[CALENDAR] insert failed", { + status: err?.code || err?.response?.status, + message: err?.message, + data: err?.response?.data, + }); + throw err; + } } // -------------------- -// TWILIO VOICE - START +// Twilio Voice - START // -------------------- -app.post("/voice", (req, res) => { - const callSid = req.body.CallSid || `local-${Date.now()}`; - const from = req.body.From || ""; // caller id (può essere forwarder) - const session = getSession(callSid); +app.post("/voice", async (req, res) => { + const respond = (xml) => res.type("text/xml").status(200).send(twiml(xml)); - session.step = 1; - session.substep = null; - session.retries = 0; - session.intent = null; - session.name = null; - session.dateISO = null; - session.time24 = null; - session.people = null; - session.waTo = null; - session.fromCaller = from; - sessions.set(callSid, session); - - const action = `${BASE_URL}/voice/step`; - - const body = twiml(` -${say("Ciao! Hai chiamato TuttiBrilli Enoteca.")} -${pause(1)} -${gatherSpeech({ - action, - prompt: "Vuoi prenotare un tavolo, oppure ti servono informazioni? Se vuoi un operatore, dimmi: operatore.", - hints: "prenotare, prenotazione, tavolo, posti, informazioni, orari, indirizzo, operatore, umano", -})} -${say("Scusami, non ti ho sentito. Riproviamo.")} -${redirect(`${BASE_URL}/voice`)} - `); - - res.type("text/xml").status(200).send(body); -}); - -// STEP HANDLER -app.post("/voice/step", async (req, res) => { - const callSid = req.body.CallSid || `local-${Date.now()}`; - const session = getSession(callSid); - - const speechRaw = (req.body.SpeechResult || "").trim(); - const speech = normalizeText(speechRaw); - const confidence = parseFloat(req.body.Confidence || "0"); - - const action = `${BASE_URL}/voice/step`; + try { + const callSid = req.body.CallSid || `local-${Date.now()}`; + const from = req.body.From || ""; // caller id (può essere forwarder) - function respond(xml) { - return res.type("text/xml").status(200).send(twiml(xml)); - } + const session = getSession(callSid); - function failOrRetry({ prompt1, prompt2, exitPrompt, forwardOnFail = true }) { - const n = incRetry(session); + // Twilio speech input + const speech = req.body.SpeechResult || ""; + const step = session.step || 1; - if (n === 1) { - sessions.set(callSid, session); - return respond(` -${gatherSpeech({ action, prompt: prompt1 })} -${redirect(action)} - `); - } - if (n === 2) { + // STEP 1: greeting + ask name + if (step === 1) { + session.step = 2; sessions.set(callSid, session); - return respond(` -${gatherSpeech({ action, prompt: prompt2 })} -${redirect(action)} - `); - } - - sessions.set(callSid, session); - - if (forwardOnFail && canForwardToHuman()) { - sessions.delete(callSid); - return respond(forwardToHumanTwiml()); - } - - sessions.delete(callSid); - return respond(` -${say(exitPrompt)} -${hangup()} - `); - } - - try { - // Inoltro a umano se richiesto - if (speech && wantsHuman(speech)) { - sessions.delete(callSid); - return respond(forwardToHumanTwiml()); - } - - const lowConfidence = - Number.isFinite(confidence) && confidence > 0 && confidence < LOW_CONFIDENCE_THRESHOLD; - - // Se bassa confidenza e già in retry, possiamo inoltrare - if (lowConfidence && session.retries >= 1 && canForwardToHuman()) { - sessions.delete(callSid); - return respond(forwardToHumanTwiml()); - } - // No input / no speech - if (!speech) { - if (session.step === 1) { - return failOrRetry({ - prompt1: "Dimmi pure: vuoi prenotare o informazioni? Oppure di' operatore.", - prompt2: "Puoi dire, per esempio: 'voglio prenotare un tavolo'. Oppure: 'operatore'.", - exitPrompt: "Non riesco a sentirti bene. Se vuoi, scrivici su WhatsApp. A presto!", - }); - } - if (session.step === 2) { - return failOrRetry({ - prompt1: "Come ti chiami?", - prompt2: "Dimmi il tuo nome, ad esempio: 'Mario Rossi'.", - exitPrompt: "Ok. Se vuoi, scrivici su WhatsApp. A presto!", - }); - } - if (session.step === 3) { - return failOrRetry({ - prompt1: "Per che giorno vuoi prenotare?", - prompt2: "Puoi dire 'domani' oppure '25 12'.", - exitPrompt: "Non riesco a prendere la data. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", - }); - } - if (session.step === 4) { - return failOrRetry({ - prompt1: "A che ora preferisci?", - prompt2: "Puoi dire '20 e 30' oppure '21'.", - exitPrompt: "Non riesco a prendere l'orario. Scrivici su WhatsApp e ti aiutiamo subito. A presto!", - }); - } - if (session.step === 5) { - return failOrRetry({ - prompt1: "Per quante persone?", - prompt2: "Dimmi un numero, ad esempio 'quattro'.", - exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", - }); - } - if (session.step === 6) { - return failOrRetry({ - prompt1: "A che numero WhatsApp vuoi ricevere la conferma? Dimmi il numero iniziando con più trentanove.", - prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", - exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", - }); - } + return respond(` +${say("Ciao! Sono l'assistente di TuttiBrilli Enoteca. Per prenotare, dimmi il tuo nome e cognome.")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi nome e cognome." })} +${say("Non ho sentito nulla. Riprova.")} +${redirect(`${BASE_URL}/voice`)} +`); } - // STEP 1: Intento - if (session.step === 1) { - if (looksLikeBooking(speech)) { - session.intent = "booking"; - session.step = 2; - resetRetries(session); + // STEP 2: collect name + if (step === 2) { + if (!speech) { + incRetries(session); + if (session.retries >= 2) { + sessions.delete(callSid); + return respond(`${say("Non riesco a sentirti bene. Riprova più tardi.")}${hangup()}`); + } sessions.set(callSid, session); - return respond(` -${say("Perfetto. Ti faccio qualche domanda veloce.")} -${pause(1)} -${gatherSpeech({ action, prompt: "Come ti chiami?" })} -${redirect(action)} - `); +${say("Non ho capito. Dimmi nome e cognome.")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi nome e cognome." })} +`); } - if (looksLikeInfo(speech)) { - sessions.delete(callSid); - return respond(` -${say("Certo. Per informazioni rapide puoi scriverci su WhatsApp. Se invece vuoi prenotare, dimmelo e ti aiuto subito.")} -${hangup()} - `); - } - - return failOrRetry({ - prompt1: "Scusami, vuoi prenotare un tavolo o informazioni? Oppure di' operatore.", - prompt2: "Puoi dire: 'prenotare un tavolo' oppure 'informazioni'. Oppure: 'operatore'.", - exitPrompt: "Va bene. Scrivici su WhatsApp e ti rispondiamo appena possibile. A presto!", - }); - } - - // STEP 2: Nome - if (session.step === 2) { - session.name = speechRaw || "(nome da confermare)"; + session.name = speech.trim(); session.step = 3; resetRetries(session); sessions.set(callSid, session); return respond(` -${say(`Piacere, ${session.name}.`)} -${pause(1)} -${gatherSpeech({ action, prompt: "Per che giorno vuoi prenotare?" })} -${redirect(action)} - `); +${say(`Perfetto ${session.name}. Per quale giorno vuoi prenotare? Puoi dire oggi, domani, oppure una data.`)} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi la data della prenotazione." })} +`); } - // STEP 3: Data - if (session.step === 3) { + // STEP 3: date + if (step === 3) { const dateISO = parseDateIT_MVP(speech); if (!dateISO) { - return failOrRetry({ - prompt1: "Non sono sicuro di aver capito la data. Per che giorno vuoi prenotare?", - prompt2: "Puoi dire 'domani' oppure '25 12'.", - exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", - }); + incRetries(session); + if (session.retries >= 2) { + sessions.delete(callSid); + return respond(`${say("Non sono riuscito a capire la data. Riprova più tardi.")}${hangup()}`); + } + sessions.set(callSid, session); + return respond(` +${say("Non ho capito la data. Puoi dire per esempio: domani, oppure 27 12 2025.")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi la data." })} +`); } session.dateISO = dateISO; @@ -692,22 +489,25 @@ ${redirect(action)} sessions.set(callSid, session); return respond(` -${say(`Ok, ${humanDateIT(session.dateISO)}.`)} -${pause(1)} -${gatherSpeech({ action, prompt: "A che ora preferisci?" })} -${redirect(action)} - `); +${say("A che ora? Dimmi un orario, per esempio venti e trenta.")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi l'orario." })} +`); } - // STEP 4: Orario - if (session.step === 4) { + // STEP 4: time + if (step === 4) { const time24 = parseTimeIT_MVP(speech); if (!time24) { - return failOrRetry({ - prompt1: "Non sono sicuro di aver capito l'orario. A che ora preferisci?", - prompt2: "Puoi dire '20 e 30' oppure '21'.", - exitPrompt: "Ok. Scrivici su WhatsApp con giorno e ora e ti confermiamo. A presto!", - }); + incRetries(session); + if (session.retries >= 2) { + sessions.delete(callSid); + return respond(`${say("Non sono riuscito a capire l'orario. Riprova più tardi.")}${hangup()}`); + } + sessions.set(callSid, session); + return respond(` +${say("Non ho capito l'orario. Puoi dire per esempio: venti e trenta.")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi l'orario." })} +`); } session.time24 = time24; @@ -716,113 +516,71 @@ ${redirect(action)} sessions.set(callSid, session); return respond(` -${say(`Perfetto, alle ${session.time24}.`)} -${pause(1)} -${gatherSpeech({ action, prompt: "Per quante persone?" })} -${redirect(action)} - `); +${say("Per quante persone?")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di persone." })} +`); } - // STEP 5: Persone - if (session.step === 5) { - const people = parsePeopleIT_MVP(speech); - if (!people) { - return failOrRetry({ - prompt1: "Quante persone sarete?", - prompt2: "Dimmi un numero, ad esempio 'quattro'.", - exitPrompt: "Ok. Scrivici su WhatsApp con numero persone e orario. A presto!", - }); + // STEP 5: people + if (step === 5) { + const ppl = parsePeopleIT_MVP(speech); + if (!ppl) { + incRetries(session); + if (session.retries >= 2) { + sessions.delete(callSid); + return respond(`${say("Non sono riuscito a capire il numero di persone. Riprova più tardi.")}${hangup()}`); + } + sessions.set(callSid, session); + return respond(` +${say("Non ho capito. Dimmi il numero di persone, per esempio due o quattro.")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di persone." })} +`); } - session.people = people; + session.people = ppl; session.step = 6; - session.substep = null; resetRetries(session); sessions.set(callSid, session); - const from = String(session.fromCaller || ""); - const fromE164 = from.startsWith("+") ? from : ""; - const canUseCaller = isLikelyItalianMobileE164(fromE164); + return respond(` +${say("Perfetto. Dimmi il tuo numero di telefono, così possiamo confermare su WhatsApp.")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di telefono." })} +`); + } - if (canUseCaller) { - session.substep = "wa_confirm_caller"; - sessions.set(callSid, session); + // STEP 6: phone / whatsapp + if (step === 6) { + // MVP: estrai un numero tipo +39... + let phone = String(speech || "").replace(/[^\d+]/g, ""); + if (phone && !phone.startsWith("+")) { + // se l'utente dice 331..., aggiungi +39 (italia) + if (phone.length >= 9 && phone.length <= 11) phone = `+39${phone}`; + } + if (!isValidPhoneE164(phone)) { + incRetries(session); + if (session.retries >= 2) { + sessions.delete(callSid); + return respond(`${say("Non sono riuscito a capire il numero. Riprova più tardi.")}${hangup()}`); + } + sessions.set(callSid, session); return respond(` -${say(`Perfetto. Ricapitolo: ${humanDateIT(session.dateISO)} alle ${session.time24}, per ${session.people} persone.`)} -${pause(1)} -${gatherSpeech({ - action, - prompt: "Ti mando la conferma su WhatsApp a questo numero. Va bene? Se vuoi un operatore, dimmi operatore.", - hints: "sì, si, va bene, ok, certo, no, cambia, un altro numero, operatore, umano", -})} -${redirect(action)} - `); +${say("Non ho capito il numero. Puoi dirlo lentamente, oppure includere il prefisso, per esempio più trentanove...")} +${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di telefono." })} +`); } - session.substep = "wa_ask_number"; + session.phone = phone; + session.waTo = `whatsapp:${phone}`; + session.step = 7; + resetRetries(session); sessions.set(callSid, session); return respond(` -${say(`Perfetto. Ricapitolo: ${humanDateIT(session.dateISO)} alle ${session.time24}, per ${session.people} persone.`)} +${say("Perfetto. Sto registrando la prenotazione.")} ${pause(1)} -${gatherSpeech({ - action, - prompt: "A che numero WhatsApp vuoi ricevere la conferma? Dimmi il numero iniziando con più trentanove.", -})} -${redirect(action)} - `); - } - - // STEP 6: WhatsApp (consenso o numero) - if (session.step === 6) { - const sub = session.substep || "wa_ask_number"; - - if (sub === "wa_confirm_caller") { - const yes = /\b(si|sì|ok|va bene|certo|confermo)\b/.test(speech); - const no = /\b(no|non va bene|cambia|altro numero)\b/.test(speech); - - if (yes && !no) { - session.waTo = `whatsapp:${session.fromCaller}`; - session.step = 7; - session.substep = null; - resetRetries(session); - sessions.set(callSid, session); - // continua a STEP 7 sotto - } else if (no && !yes) { - session.substep = "wa_ask_number"; - resetRetries(session); - sessions.set(callSid, session); - return respond(` -${gatherSpeech({ action, prompt: "Ok. Dimmi il numero WhatsApp, iniziando con più trentanove." })} -${redirect(action)} - `); - } else { - return failOrRetry({ - prompt1: "Scusami, ti va bene che invii il WhatsApp a questo numero? Puoi dire sì o no. Oppure: operatore.", - prompt2: "Dimmi solo: sì, va bene. Oppure: no, un altro numero. Oppure: operatore.", - exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", - }); - } - } - - if (sub === "wa_ask_number") { - const waTo = normalizeWhatsappFromVoice(speechRaw || speech); - if (!waTo || !hasValidWaAddress(waTo)) { - return failOrRetry({ - prompt1: "Non sono sicuro di aver capito il numero. Me lo ripeti iniziando con più trentanove?", - prompt2: "Ripetilo lentamente, ad esempio: più trentanove, tre tre tre...", - exitPrompt: "Ok. Scrivici tu su WhatsApp e ti confermiamo lì. A presto!", - }); - } - - session.waTo = waTo; - session.step = 7; - session.substep = null; - resetRetries(session); - sessions.set(callSid, session); - // continua a STEP 7 sotto - } +${redirect(`${BASE_URL}/voice`)} +`); } // STEP 7: crea evento su Calendar + invia WhatsApp (SOLO SE CALENDAR OK) @@ -837,18 +595,15 @@ ${redirect(action)} dateISO: session.dateISO, time24: session.time24, people: session.people, - phone: (session.fromCaller || "").startsWith("+") ? session.fromCaller : "", + phone: session.phone, waTo: session.waTo, }); - - // Log utile per debug - console.log("CALENDAR OK:", { - reused: calendarResult.reused, - eventId: calendarResult.eventId, - htmlLink: calendarResult.htmlLink, - }); } catch (e) { - console.error("Google Calendar insert failed:", e); + console.error("[VOICE] createBookingEvent failed:", { + message: e?.message, + status: e?.code || e?.response?.status, + data: e?.response?.data, + }); sessions.delete(callSid); return respond(` @@ -868,20 +623,14 @@ ${hangup()} } // 2) WhatsApp (SOLO DOPO Calendar OK) - const waTo = session.waTo; - const waBody = - `✅ Prenotazione confermata\n` + - `Nome: ${session.name}\n` + - `Data: ${humanDateIT(session.dateISO)}\n` + - `Ora: ${session.time24}\n` + - `Persone: ${session.people}\n\n` + - `Se devi modificare o annullare, rispondi a questo messaggio.`; - try { + const waTo = session.waTo; + const waBody = `Ciao ${session.name}! Prenotazione registrata ✅\nData: ${session.dateISO}\nOra: ${session.time24}\nPersone: ${session.people}\nA presto da TuttiBrilli!`; + if (!twilioClient) { - console.error("Twilio client non configurato: mancano TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); - } else if (!TWILIO_WHATSAPP_FROM || !/^whatsapp:\+\d{8,15}$/.test(TWILIO_WHATSAPP_FROM)) { - console.error("TWILIO_WHATSAPP_FROM non valido:", TWILIO_WHATSAPP_FROM); + console.error("Twilio client non configurato: manca TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); + } else if (!TWILIO_WHATSAPP_FROM) { + console.error("TWILIO_WHATSAPP_FROM non valido/mancante:", TWILIO_WHATSAPP_FROM); } else if (!waTo || !hasValidWaAddress(waTo)) { console.error("waTo non valido:", waTo); } else { @@ -893,7 +642,7 @@ ${hangup()} } } catch (e) { console.error("WhatsApp send failed:", e); - // Non blocchiamo: l'evento è già su Calendar + // Non blocchiamo: evento già creato } sessions.delete(callSid); @@ -903,17 +652,16 @@ ${hangup()} `); } - // fallback finale + // fallback sessions.delete(callSid); return respond(` ${say("Ripartiamo da capo.")} ${redirect(`${BASE_URL}/voice`)} - `); +`); + } catch (err) { console.error("VOICE FLOW ERROR:", err); - sessions.delete(callSid); - // Se vuoi, su errore tecnico inoltra a operatore (se configurato) if (canForwardToHuman()) { return res.type("text/xml").status(200).send(twiml(forwardToHumanTwiml())); } @@ -922,42 +670,45 @@ ${redirect(`${BASE_URL}/voice`)} twiml(` ${say("C'è stato un problema tecnico. Riprova tra poco.")} ${hangup()} - `) +`) ); } }); -// ==================== + +// -------------------- // DEBUG CALENDAR TEST -// ==================== +// -------------------- app.get("/debug/calendar-test", async (req, res) => { try { console.log("[DEBUG] calendar-test called"); - const dateISO = req.query.dateISO || new Date().toISOString().slice(0, 10); - const timeHHMM = req.query.timeHHMM || "20:30"; + const dateISO = String(req.query.dateISO || new Date().toISOString().slice(0, 10)); + const timeHHMM = String(req.query.timeHHMM || "20:30"); const result = await createBookingEvent({ + callSid: "DEBUG-CALLSID", name: "TEST TuttiBrilli", phone: "+391234567890", dateISO, - timeHHMM, - partySize: 2, + timeHHMM, // verrà mappato su time24 + partySize: 2, // verrà mappato su people + waTo: "whatsapp:+391234567890", notes: "Evento di test creato da /debug/calendar-test", }); - if (result.ok) { - return res.json({ ok: true, eventId: result.eventId }); - } - - return res.status(500).json({ - ok: false, - error: result.error || "Calendar insert failed", - }); + return res.json({ ok: true, ...result }); } catch (err) { - console.error("[DEBUG] calendar-test error:", err); + console.error("[DEBUG] calendar-test error:", { + message: err?.message, + status: err?.code || err?.response?.status, + data: err?.response?.data, + }); + return res.status(500).json({ ok: false, - message: err.message, + message: err?.message, + status: err?.code || err?.response?.status, + data: err?.response?.data, }); } }); From 82e435ee0014f8866e1d08edccf2f8d85e316826 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 01:11:19 +0100 Subject: [PATCH 028/143] Refactor Google Calendar event creation and logging Refactor Google Calendar event creation and improve error handling. Update timezone handling and logging for better clarity. --- app.js | 108 +++++++++++++++++++++++++++------------------------------ 1 file changed, 52 insertions(+), 56 deletions(-) diff --git a/app.js b/app.js index fb4ab8b86d..59cb66fe54 100644 --- a/app.js +++ b/app.js @@ -15,12 +15,12 @@ const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ -// (OPZIONALE ma consigliato) per evitare problemi di JSON spezzato nelle ENV -// Metti qui il JSON del service account in Base64 +// Service account: consigliato B64 const GOOGLE_SERVICE_ACCOUNT_JSON_B64 = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64 || ""; const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ""; const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; // es: ...@group.calendar.google.com +const GOOGLE_CALENDAR_TZ = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; const DEFAULT_EVENT_DURATION_MINUTES = parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || "120", 10); // Inoltro chiamata a operatore (opzionale) @@ -44,12 +44,11 @@ const twilioClient = // Google Calendar helpers // -------------------- function getServiceAccountJsonRaw() { - // preferisci B64 se presente if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { try { return Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf8"); } catch (e) { - console.error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 decode failed:", e?.message || e); + console.error("[CALENDAR] GOOGLE_SERVICE_ACCOUNT_JSON_B64 decode failed:", e?.message || e); return ""; } } @@ -156,21 +155,6 @@ function gatherSpeech({ // -------------------- // Sessioni in memoria (MVP). In produzione: Redis/DB. // -------------------- -/** - * session schema: - * { - * step: number, - * substep?: string|null, - * retries: number, - * intent: "booking"|"info"|null, - * name: string|null, - * dateISO: "YYYY-MM-DD"|null, - * time24: "HH:MM"|null, - * people: number|null, - * waTo: "whatsapp:+39..."|null, - * phone: string|null, - * } - */ const sessions = new Map(); function getSession(callSid) { @@ -219,10 +203,9 @@ function toISODate(d) { } function parseDateIT_MVP(speech) { - // MVP: accetta "oggi", "domani" o "YYYY-MM-DD" o "27 dicembre" etc. const t = normalizeText(speech); - const today = nowLocal(); + if (t.includes("oggi")) return toISODate(today); if (t.includes("domani")) { const d = new Date(today.getTime() + 24 * 60 * 60 * 1000); @@ -233,7 +216,7 @@ function parseDateIT_MVP(speech) { const iso = t.match(/(\d{4})-(\d{2})-(\d{2})/); if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; - // match dd/mm/yyyy + // match dd/mm/yyyy or dd-mm-yyyy const dmY = t.match(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/); if (dmY) { const dd = String(dmY[1]).padStart(2, "0"); @@ -246,7 +229,6 @@ function parseDateIT_MVP(speech) { } function parseTimeIT_MVP(speech) { - // MVP: accetta "20 30", "20:30", "alle 8 e mezza" const t = normalizeText(speech); const hm = t.match(/(\d{1,2})[:\s](\d{2})/); @@ -312,14 +294,13 @@ async function createBookingEvent({ // accettiamo anche i nomi del debug endpoint timeHHMM, partySize, - notes, // opzionale + notes, }) { - // ============================ - // NORMALIZZAZIONE INPUT + GUARDRAILS - // ============================ + // Normalizzazione nomi if (people == null && partySize != null) people = partySize; if (!time24 && timeHHMM) time24 = timeHHMM; + // Guardrails if (!name) throw new Error("createBookingEvent: name mancante"); if (!dateISO || !/^\d{4}-\d{2}-\d{2}$/.test(dateISO)) { throw new Error(`createBookingEvent: dateISO non valido: ${dateISO}`); @@ -340,9 +321,7 @@ async function createBookingEvent({ const privateKey = `callsid:${callSid || "no-callsid"}`; - // ============================ - // IDEMPOTENZA: evita doppioni su retry Twilio - // ============================ + // Idempotenza: se già creato per lo stesso callSid, non ricreare const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -361,21 +340,41 @@ async function createBookingEvent({ } // ============================ - // START/END RFC3339 ROBUSTI (anti 400) + // ✅ FIX FUSO ORARIO: + // inviamo a Google ORA LOCALE + timeZone Europe/Rome + // NIENTE "Z" e NIENTE toISOString() // ============================ - const tz = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; + const tz = GOOGLE_CALENDAR_TZ; + + const [hhStr, mmStr] = time24.split(":"); + const startH = Number(hhStr); + const startM = Number(mmStr); - // NOTA: aggiungiamo "Z" per evitare ambiguità timezone sul server (Render) - const start = new Date(`${dateISO}T${time24}:00Z`); - if (Number.isNaN(start.getTime())) { - throw new Error(`createBookingEvent: start invalido: ${dateISO} ${time24}`); + if (!Number.isFinite(startH) || !Number.isFinite(startM) || startH < 0 || startH > 23 || startM < 0 || startM > 59) { + throw new Error(`createBookingEvent: time24 non valido: ${time24}`); } - const end = addMinutes(start, DEFAULT_EVENT_DURATION_MINUTES); - if (Number.isNaN(end.getTime())) { - throw new Error(`createBookingEvent: end invalido da start: ${start.toISOString()}`); + // Calcola end locale (gestisce anche il passaggio al giorno dopo) + const duration = DEFAULT_EVENT_DURATION_MINUTES; + const startTotal = startH * 60 + startM; + const endTotal = startTotal + duration; + + const endDayOffset = Math.floor(endTotal / (24 * 60)); // 0 o 1 (o più, ma improbabile) + const endMinutesOfDay = endTotal % (24 * 60); + const endH = Math.floor(endMinutesOfDay / 60); + const endM = endMinutesOfDay % 60; + + // dateISO + offset giorni se sfora mezzanotte + let endDateISO = dateISO; + if (endDayOffset > 0) { + const d = new Date(`${dateISO}T00:00:00`); + d.setDate(d.getDate() + endDayOffset); + endDateISO = toISODate(d); } + const startLocal = `${dateISO}T${String(startH).padStart(2, "0")}:${String(startM).padStart(2, "0")}:00`; + const endLocal = `${endDateISO}T${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}:00`; + const requestBody = { summary: `TuttiBrilli - ${name} - ${peopleNum} pax`, description: [ @@ -389,8 +388,8 @@ async function createBookingEvent({ ] .filter(Boolean) .join("\n"), - start: { dateTime: start.toISOString(), timeZone: tz }, - end: { dateTime: end.toISOString(), timeZone: tz }, + start: { dateTime: startLocal, timeZone: tz }, + end: { dateTime: endLocal, timeZone: tz }, }; console.log("[CALENDAR] requestBody:", JSON.stringify(requestBody, null, 2)); @@ -420,11 +419,8 @@ app.post("/voice", async (req, res) => { try { const callSid = req.body.CallSid || `local-${Date.now()}`; - const from = req.body.From || ""; // caller id (può essere forwarder) - const session = getSession(callSid); - // Twilio speech input const speech = req.body.SpeechResult || ""; const step = session.step || 1; @@ -550,10 +546,8 @@ ${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di telefo // STEP 6: phone / whatsapp if (step === 6) { - // MVP: estrai un numero tipo +39... let phone = String(speech || "").replace(/[^\d+]/g, ""); if (phone && !phone.startsWith("+")) { - // se l'utente dice 331..., aggiungi +39 (italia) if (phone.length >= 9 && phone.length <= 11) phone = `+39${phone}`; } @@ -585,9 +579,9 @@ ${redirect(`${BASE_URL}/voice`)} // STEP 7: crea evento su Calendar + invia WhatsApp (SOLO SE CALENDAR OK) if (session.step === 7) { - // 1) Google Calendar (DEVE andare a buon fine) let calendarResult = null; + // 1) Google Calendar (DEVE andare a buon fine) try { calendarResult = await createBookingEvent({ callSid, @@ -613,8 +607,7 @@ ${hangup()} } if (!calendarResult) { - console.error("Google Calendar insert failed: calendarResult is null/undefined"); - + console.error("[VOICE] createBookingEvent returned null/undefined"); sessions.delete(callSid); return respond(` ${say("Non sono riuscito a registrare la prenotazione in calendario. Riprova tra poco oppure scrivici su WhatsApp.")} @@ -625,14 +618,17 @@ ${hangup()} // 2) WhatsApp (SOLO DOPO Calendar OK) try { const waTo = session.waTo; - const waBody = `Ciao ${session.name}! Prenotazione registrata ✅\nData: ${session.dateISO}\nOra: ${session.time24}\nPersone: ${session.people}\nA presto da TuttiBrilli!`; + const waBody = + `Ciao ${session.name}! Prenotazione registrata ✅\n` + + `Data: ${session.dateISO}\nOra: ${session.time24}\nPersone: ${session.people}\n` + + `A presto da TuttiBrilli!`; if (!twilioClient) { - console.error("Twilio client non configurato: manca TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); + console.error("[WA] Twilio client non configurato: manca TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); } else if (!TWILIO_WHATSAPP_FROM) { - console.error("TWILIO_WHATSAPP_FROM non valido/mancante:", TWILIO_WHATSAPP_FROM); + console.error("[WA] TWILIO_WHATSAPP_FROM mancante:", TWILIO_WHATSAPP_FROM); } else if (!waTo || !hasValidWaAddress(waTo)) { - console.error("waTo non valido:", waTo); + console.error("[WA] waTo non valido:", waTo); } else { await twilioClient.messages.create({ from: TWILIO_WHATSAPP_FROM, @@ -641,7 +637,7 @@ ${hangup()} }); } } catch (e) { - console.error("WhatsApp send failed:", e); + console.error("[WA] WhatsApp send failed:", e); // Non blocchiamo: evento già creato } @@ -658,9 +654,8 @@ ${hangup()} ${say("Ripartiamo da capo.")} ${redirect(`${BASE_URL}/voice`)} `); - } catch (err) { - console.error("VOICE FLOW ERROR:", err); + console.error("[VOICE] FLOW ERROR:", err); if (canForwardToHuman()) { return res.type("text/xml").status(200).send(twiml(forwardToHumanTwiml())); @@ -722,6 +717,7 @@ app.listen(PORT, () => { const raw = getServiceAccountJsonRaw(); console.log(`Calendar configured: ${Boolean(raw && GOOGLE_CALENDAR_ID)}`); + console.log(`Calendar TZ: ${GOOGLE_CALENDAR_TZ}`); console.log(`Twilio configured: ${Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN)}`); console.log( From e2b2ff94ce23e8586cb554d2e243354713834af7 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 01:46:00 +0100 Subject: [PATCH 029/143] Update app.js --- app.js | 1604 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 1072 insertions(+), 532 deletions(-) diff --git a/app.js b/app.js index 59cb66fe54..8283ebfdf1 100644 --- a/app.js +++ b/app.js @@ -1,189 +1,200 @@ "use strict"; +/** + * TuttiBrilli Enoteca - Voice Booking Assistant (Single file, copy/paste) + * - Twilio Voice (Speech Gather) -> Node/Express + * - Google Calendar (service account) for booking events + * - Twilio WhatsApp confirmation ONLY after calendar success + * + * NEW FEATURES: + * - Block bookings if Calendar has an event containing "locale chiuso" (summary/description) on that date + * - Preorder structured menu options: + * - cena + * - apericena + * - dopocena (only after 22:30) + * - Piatto Apericena (25€) + * - Piatto Apericena Promo (eligible only if: Tue-Sun, NOT Fri, NOT holidays, and no "no promo" marker on calendar date) + * - Always record allergies/intolerances/special requests in Calendar + WhatsApp + * + * ENV REQUIRED: + * PORT + * BASE_URL (e.g. https://your-service.onrender.com) <-- IMPORTANT for Twilio action URL + * + * TWILIO_ACCOUNT_SID + * TWILIO_AUTH_TOKEN + * TWILIO_WHATSAPP_FROM (e.g. whatsapp:+14155238886 or approved sender) + * + * GOOGLE_CALENDAR_ID + * GOOGLE_CALENDAR_TZ (default Europe/Rome) + * GOOGLE_SERVICE_ACCOUNT_JSON_B64 (recommended) OR GOOGLE_SERVICE_ACCOUNT_JSON (raw JSON string) + * + * OPTIONAL: + * ENABLE_FORWARDING=true/false + * HUMAN_FORWARD_TO=+39... + * + * HOLIDAYS_YYYY_MM_DD="2025-01-01,2025-04-20,2025-12-25" // for promo exclusion on holidays + */ + const express = require("express"); -const app = express(); +const twilio = require("twilio"); +const { google } = require("googleapis"); -// Body parsers per Twilio (x-www-form-urlencoded) + JSON +const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); -// ENV +// ======================= ENV ======================= const PORT = process.env.PORT || 3001; -const BASE_URL = process.env.BASE_URL || ""; // es: https://ai-backoffice-tuttibrilli.onrender.com +const BASE_URL = process.env.BASE_URL || ""; const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; -const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; // es: whatsapp:+14155238886 (sandbox) oppure whatsapp:+ +const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; -// Service account: consigliato B64 +const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; +const GOOGLE_CALENDAR_TZ = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; const GOOGLE_SERVICE_ACCOUNT_JSON_B64 = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64 || ""; const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ""; -const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; // es: ...@group.calendar.google.com -const GOOGLE_CALENDAR_TZ = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; -const DEFAULT_EVENT_DURATION_MINUTES = parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || "120", 10); - -// Inoltro chiamata a operatore (opzionale) const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "false").toLowerCase() === "true"; -const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; // es: +39333... +const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; -// OpenAI key presente nel tuo progetto ma qui non è usata direttamente -const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; +const HOLIDAYS_YYYY_MM_DD = process.env.HOLIDAYS_YYYY_MM_DD || ""; -// Lib -const twilio = require("twilio"); -const { google } = require("googleapis"); +const HOLIDAYS_SET = new Set( + HOLIDAYS_YYYY_MM_DD + .split(",") + .map((s) => s.trim()) + .filter(Boolean) +); -// Clients const twilioClient = - TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN - ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) - : null; - -// -------------------- -// Google Calendar helpers -// -------------------- -function getServiceAccountJsonRaw() { - if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { - try { - return Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf8"); - } catch (e) { - console.error("[CALENDAR] GOOGLE_SERVICE_ACCOUNT_JSON_B64 decode failed:", e?.message || e); - return ""; - } - } - return GOOGLE_SERVICE_ACCOUNT_JSON; -} - -function getCalendarClient() { - if (!GOOGLE_CALENDAR_ID) { - console.error("[CALENDAR] Missing GOOGLE_CALENDAR_ID"); - return null; - } - - const raw = getServiceAccountJsonRaw(); - if (!raw) { - console.error( - "[CALENDAR] Missing service account JSON (GOOGLE_SERVICE_ACCOUNT_JSON or GOOGLE_SERVICE_ACCOUNT_JSON_B64)" - ); - return null; - } - - let creds; - try { - creds = JSON.parse(raw); - } catch (e) { - console.error("[CALENDAR] Service account JSON parse failed:", e?.message || e); - console.error("[CALENDAR] raw length:", raw.length); - return null; - } - - if (!creds?.client_email || !creds?.private_key) { - console.error("[CALENDAR] Invalid service account JSON: missing client_email/private_key"); - return null; - } - - const auth = new google.auth.JWT({ - email: creds.client_email, - key: creds.private_key, - scopes: ["https://www.googleapis.com/auth/calendar"], - }); - - return google.calendar({ version: "v3", auth }); -} - -// -------------------- -// Pagine base -// -------------------- -app.get("/", (req, res) => res.status(200).send("AI TuttiBrilli backend attivo")); -app.get("/healthz", (req, res) => res.status(200).json({ status: "ok" })); -app.get("/voice", (req, res) => res.status(200).send("OK (Twilio usa POST su /voice)")); - -// -------------------- -// Helpers TwiML -// -------------------- -function xmlEscape(s) { - return String(s ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function twiml(xmlInsideResponseTag) { - return `${xmlInsideResponseTag}`; -} - -function say(text) { - const safe = xmlEscape(text); - return `${safe}`; -} - -function pause(len = 1) { - return ``; -} - -function redirect(url) { - return `${xmlEscape(url)}`; -} - -function hangup() { - return ``; -} - -function gatherSpeech({ - action, - method = "POST", - timeout = 6, - speechTimeout = "auto", - language = "it-IT", - prompt, - hints = "", -}) { - const safeAction = xmlEscape(action); - const safePrompt = xmlEscape(prompt || ""); - const safeHints = xmlEscape(hints || ""); - - return ` - - ${safePrompt} - -`; -} - -// -------------------- -// Sessioni in memoria (MVP). In produzione: Redis/DB. -// -------------------- + TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) : null; + +// ======================= CONFIG: OPENING HOURS ======================= +// 0=Sunday ... 6=Saturday +const OPENING = { + closedDay: 1, // Monday + restaurant: { + default: { start: "18:30", end: "22:30" }, // Tue-Thu, Sun + friSat: { start: "18:30", end: "23:00" }, // Fri-Sat + }, + drinksOnly: { start: "18:30", end: "24:00" }, // everyday + musicNights: { days: [3, 5], from: "20:00" }, // Wed(3) & Fri(5) +}; + +// ======================= CONFIG: PREORDER MENU ======================= +const PREORDER_OPTIONS = [ + { key: "cena", label: "Cena", priceEUR: null, constraints: {} }, + { key: "apericena", label: "Apericena", priceEUR: null, constraints: {} }, + { key: "dopocena", label: "Dopocena", priceEUR: null, constraints: { minTime: "22:30" } }, // After 22:30 + { key: "piatto_apericena", label: "Piatto Apericena", priceEUR: 25, constraints: {} }, + { + key: "piatto_apericena_promo", + label: "Piatto Apericena in promo (previa registrazione)", + priceEUR: null, // promo price not specified + constraints: { promoOnly: true }, + }, +]; + +// ======================= CONFIG: TABLES ======================= +const TABLES = [ + // INSIDE + { id: "T1", area: "inside", min: 2, max: 4, notes: "più riservato" }, + { id: "T2", area: "inside", min: 2, max: 4, notes: "più riservato" }, + { id: "T3", area: "inside", min: 2, max: 4, notes: "più riservato" }, + { id: "T4", area: "inside", min: 2, max: 4, notes: "più riservato" }, + { id: "T5", area: "inside", min: 2, max: 2 }, + { id: "T6", area: "inside", min: 2, max: 4 }, + { id: "T7", area: "inside", min: 2, max: 4 }, + { id: "T8", area: "inside", min: 2, max: 4 }, + { id: "T9", area: "inside", min: 2, max: 2 }, + { id: "T10", area: "inside", min: 2, max: 2 }, + { id: "T11", area: "inside", min: 2, max: 4, notes: "vicino ingresso" }, + { id: "T12", area: "inside", min: 2, max: 4 }, + { id: "T13", area: "inside", min: 2, max: 4 }, + { id: "T14", area: "inside", min: 4, max: 8, notes: "divanetto con tavolino" }, + { id: "T15", area: "inside", min: 4, max: 8, notes: "divanetto con tavolino" }, + { id: "T16", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, + { id: "T17", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, + + // OUTSIDE + { id: "T1F", area: "outside", min: 2, max: 2, notes: "botte con sgabelli" }, + { id: "T2F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, + { id: "T3F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, + { id: "T4F", area: "outside", min: 4, max: 5, notes: "divanetti" }, + { id: "T6F", area: "outside", min: 4, max: 4, notes: "divanetti" }, + { id: "T7F", area: "outside", min: 4, max: 4, notes: "divanetti" }, + { id: "T8F", area: "outside", min: 4, max: 4, notes: "divanetti" }, +]; + +const TABLE_COMBINATIONS = [ + // INSIDE unions + { displayId: "T1", area: "inside", replaces: ["T1", "T2"], min: 6, max: 6, notes: "unione T1+T2" }, + { displayId: "T3", area: "inside", replaces: ["T3", "T4"], min: 6, max: 6, notes: "unione T3+T4" }, + { displayId: "T14", area: "inside", replaces: ["T14", "T15"], min: 8, max: 18, notes: "unione T14+T15" }, + { displayId: "T11", area: "inside", replaces: ["T11", "T12"], min: 6, max: 6, notes: "unione T11+T12" }, + { displayId: "T12", area: "inside", replaces: ["T12", "T13"], min: 6, max: 6, notes: "unione T12+T13" }, + { displayId: "T11", area: "inside", replaces: ["T11", "T12", "T13"], min: 8, max: 10, notes: "unione T11+T12+T13" }, + { displayId: "T16", area: "inside", replaces: ["T16", "T17"], min: 8, max: 10, notes: "unione T16+T17" }, + + // OUTSIDE union + { displayId: "T7F", area: "outside", replaces: ["T7F", "T8F"], min: 6, max: 8, notes: "unione T7F+T8F" }, +]; + +// ======================= SESSIONS (in-memory) ======================= const sessions = new Map(); function getSession(callSid) { + if (!callSid) return null; if (!sessions.has(callSid)) { sessions.set(callSid, { step: 1, - substep: null, retries: 0, - intent: null, + name: null, dateISO: null, time24: null, people: null, - waTo: null, + + specialRequestsRaw: null, + + preorderOptIn: null, + preorderChoiceKey: null, + preorderLabel: null, + + promoEligible: null, // computed from calendar/day rules + + // area + area: null, // inside/outside + pendingOutsideConfirm: false, + phone: null, + waTo: null, + + // table allocation result + tableDisplayId: null, + tableLocks: [], + tableNotes: null, + + // derived + durationMinutes: null, + bookingType: "restaurant", // or drinks/operator + autoConfirm: true, }); } return sessions.get(callSid); } -function resetRetries(session) { - session.retries = 0; -} - -function incRetries(session) { +function bumpRetries(session) { session.retries = (session.retries || 0) + 1; return session.retries; } +function resetRetries(session) { + session.retries = 0; +} +// ======================= TEXT / PARSERS ======================= function normalizeText(s) { return String(s || "") .trim() @@ -191,10 +202,6 @@ function normalizeText(s) { .replace(/\s+/g, " "); } -function nowLocal() { - return new Date(); -} - function toISODate(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -202,7 +209,11 @@ function toISODate(d) { return `${y}-${m}-${day}`; } -function parseDateIT_MVP(speech) { +function nowLocal() { + return new Date(); +} + +function parseDateIT(speech) { const t = normalizeText(speech); const today = nowLocal(); @@ -212,42 +223,48 @@ function parseDateIT_MVP(speech) { return toISODate(d); } - // match YYYY-MM-DD const iso = t.match(/(\d{4})-(\d{2})-(\d{2})/); if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; - // match dd/mm/yyyy or dd-mm-yyyy - const dmY = t.match(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/); + const dmY = t.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); if (dmY) { - const dd = String(dmY[1]).padStart(2, "0"); - const mm = String(dmY[2]).padStart(2, "0"); - const yyyy = dmY[3]; - return `${yyyy}-${mm}-${dd}`; + let dd = Number(dmY[1]); + let mm = Number(dmY[2]); + let yy = dmY[3] ? Number(dmY[3]) : today.getFullYear(); + if (yy < 100) yy += 2000; + if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { + const d = new Date(yy, mm - 1, dd); + return toISODate(d); + } } return null; } -function parseTimeIT_MVP(speech) { +function parseTimeIT(speech) { const t = normalizeText(speech); const hm = t.match(/(\d{1,2})[:\s](\d{2})/); if (hm) { - const hh = String(hm[1]).padStart(2, "0"); - const mm = String(hm[2]).padStart(2, "0"); - return `${hh}:${mm}`; + const hh = Number(hm[1]); + const mm = Number(hm[2]); + if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; + } } const onlyH = t.match(/\b(\d{1,2})\b/); if (onlyH) { - const hh = String(onlyH[1]).padStart(2, "0"); - return `${hh}:00`; + const hh = Number(onlyH[1]); + if (hh >= 0 && hh <= 23) { + return `${String(hh).padStart(2, "0")}:00`; + } } return null; } -function parsePeopleIT_MVP(speech) { +function parsePeopleIT(speech) { const t = normalizeText(speech); const m = t.match(/\b(\d{1,2})\b/); if (!m) return null; @@ -256,16 +273,151 @@ function parsePeopleIT_MVP(speech) { return n; } -function hasValidWaAddress(s) { - return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); +function parseYesNoIT(speech) { + const t = normalizeText(speech); + if (!t) return null; + if (t.includes("si") || t.includes("sì") || t.includes("certo") || t.includes("ok") || t.includes("va bene")) return true; + if (t.includes("no") || t.includes("non") || t.includes("niente") || t.includes("nessun")) return false; + return null; +} + +function parseAreaIT(speech) { + const t = normalizeText(speech); + if (t.includes("intern") || t.includes("dentro") || t.includes("sala")) return "inside"; + if (t.includes("estern") || t.includes("fuori") || t.includes("terraz") || t.includes("giardino")) return "outside"; + return null; } function isValidPhoneE164(s) { return /^\+\d{8,15}$/.test(String(s || "").trim()); } -function addMinutes(date, minutes) { - return new Date(date.getTime() + minutes * 60 * 1000); +function hasValidWaAddress(s) { + return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); +} + +function extractPhoneFromSpeech(speech) { + const t = normalizeText(speech).replace(/[^\d+]/g, ""); + if (isValidPhoneE164(t)) return t; + const digits = normalizeText(speech).replace(/[^\d]/g, ""); + if (digits.length >= 9 && digits.length <= 11) return `+39${digits}`; + return null; +} + +// Preorder parsing: understand fixed options by keywords +function parsePreorderChoiceKey(speech) { + const t = normalizeText(speech); + + // Strong matches first + if (t.includes("promo")) return "piatto_apericena_promo"; + if (t.includes("piatto") && t.includes("apericena")) return "piatto_apericena"; + if (t.includes("dopo") || t.includes("dopocena")) return "dopocena"; + if (t.includes("apericena")) return "apericena"; + if (t.includes("cena")) return "cena"; + if (t.includes("nessuno") || t.includes("nessuna") || t.includes("no")) return null; + + return "unknown"; +} + +function getPreorderOptionByKey(key) { + return PREORDER_OPTIONS.find((o) => o.key === key) || null; +} + +function hmToMinutes(hm) { + const [h, m] = String(hm).split(":").map(Number); + return h * 60 + m; +} + +function isTimeAtOrAfter(time24, minTime24) { + return hmToMinutes(time24) >= hmToMinutes(minTime24); +} + +// ======================= TIME / OPENING VALIDATION ======================= +function getRestaurantWindowForDay(day) { + if (day === 5 || day === 6) return OPENING.restaurant.friSat; // Fri, Sat + return OPENING.restaurant.default; +} + +function isWithinWindow(time24, startHM, endHM) { + const t = hmToMinutes(time24); + const start = hmToMinutes(startHM); + const end = hmToMinutes(endHM); + return t >= start && t <= end; +} + +function getDurationMinutes(people, dateObj) { + let minutes; + if (people <= 4) minutes = 120; + else if (people <= 8) minutes = 150; + else minutes = 180; + + const day = dateObj.getDay(); + const isMusicNight = OPENING.musicNights.days.includes(day); + if (isMusicNight && people <= 8) minutes += 30; + + return minutes; +} + +function computeStartEndLocal(dateISO, time24, durationMinutes) { + const [sh, sm] = time24.split(":").map(Number); + const startTotal = sh * 60 + sm; + const endTotal = startTotal + durationMinutes; + + const endDayOffset = Math.floor(endTotal / (24 * 60)); + const endMinutesOfDay = endTotal % (24 * 60); + const endH = Math.floor(endMinutesOfDay / 60); + const endM = endMinutesOfDay % 60; + + let endDateISO = dateISO; + if (endDayOffset > 0) { + const d = new Date(`${dateISO}T00:00:00`); + d.setDate(d.getDate() + endDayOffset); + endDateISO = toISODate(d); + } + + const startLocal = `${dateISO}T${String(sh).padStart(2, "0")}:${String(sm).padStart(2, "0")}:00`; + const endLocal = `${endDateISO}T${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}:00`; + + return { startLocal, endLocal }; +} + +function deriveBookingTypeAndConfirm(dateISO, time24) { + const d = new Date(`${dateISO}T00:00:00`); + const day = d.getDay(); + + if (day === OPENING.closedDay) { + return { bookingType: "operator", autoConfirm: false, reason: "Lunedì chiuso" }; + } + + const restWin = getRestaurantWindowForDay(day); + const inRestaurant = isWithinWindow(time24, restWin.start, restWin.end); + const inDrinks = isWithinWindow(time24, OPENING.drinksOnly.start, OPENING.drinksOnly.end); + + if (inRestaurant) return { bookingType: "restaurant", autoConfirm: true, reason: null }; + if (inDrinks) return { bookingType: "drinks", autoConfirm: true, reason: "Fuori orario cucina" }; + + return { bookingType: "closed", autoConfirm: false, reason: "Fuori orario" }; +} + +// ======================= TWILIO TWIML HELPERS ======================= +function buildTwiml() { + return new twilio.twiml.VoiceResponse(); +} + +function sayIt(response, text) { + response.say({ voice: "alice", language: "it-IT" }, text); +} + +function gatherSpeech(response, promptText) { + const actionUrl = `${BASE_URL}/voice`; + const gather = response.gather({ + input: "speech", + language: "it-IT", + speechTimeout: "auto", + action: actionUrl, + method: "POST", + }); + sayIt(gather, promptText); } function canForwardToHuman() { @@ -273,15 +425,166 @@ function canForwardToHuman() { } function forwardToHumanTwiml() { - return ` - ${say("Ti metto in contatto con un operatore.")} - ${xmlEscape(HUMAN_FORWARD_TO)} - `; + const vr = buildTwiml(); + sayIt(vr, "Ti passo subito un operatore. Resta in linea."); + vr.dial({}, HUMAN_FORWARD_TO); + return vr.toString(); } -// -------------------- -// Google Calendar booking -// -------------------- +// ======================= GOOGLE CALENDAR CLIENT ======================= +function getServiceAccountJsonRaw() { + if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { + try { + const decoded = Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf-8"); + JSON.parse(decoded); + return decoded; + } catch (e) { + console.error("[CALENDAR] Invalid GOOGLE_SERVICE_ACCOUNT_JSON_B64:", e); + return ""; + } + } + if (GOOGLE_SERVICE_ACCOUNT_JSON) { + try { + JSON.parse(GOOGLE_SERVICE_ACCOUNT_JSON); + return GOOGLE_SERVICE_ACCOUNT_JSON; + } catch (e) { + console.error("[CALENDAR] Invalid GOOGLE_SERVICE_ACCOUNT_JSON:", e); + return ""; + } + } + return ""; +} + +function getCalendarClient() { + if (!GOOGLE_CALENDAR_ID) { + console.error("[CALENDAR] Missing GOOGLE_CALENDAR_ID"); + return null; + } + const raw = getServiceAccountJsonRaw(); + if (!raw) { + console.error("[CALENDAR] Missing service account JSON"); + return null; + } + + const creds = JSON.parse(raw); + const scopes = ["https://www.googleapis.com/auth/calendar"]; + const auth = new google.auth.JWT(creds.client_email, null, creds.private_key, scopes); + return google.calendar({ version: "v3", auth }); +} + +// ======================= CALENDAR MARKERS (locale chiuso / no promo) ======================= +function containsMarker(ev, markerLower) { + const s = `${String(ev.summary || "")}\n${String(ev.description || "")}`.toLowerCase(); + return s.includes(markerLower); +} + +async function calendarHasMarkerOnDate(calendar, dateISO, markerLower) { + const timeMin = `${dateISO}T00:00:00Z`; + const timeMax = `${dateISO}T23:59:59Z`; + const resp = await calendar.events.list({ + calendarId: GOOGLE_CALENDAR_ID, + timeMin, + timeMax, + singleEvents: true, + maxResults: 250, + orderBy: "startTime", + }); + + return (resp.data.items || []).some((ev) => containsMarker(ev, markerLower)); +} + +function isPromoEligibleByDay(dateISO) { + const d = new Date(`${dateISO}T00:00:00`); + const day = d.getDay(); // 0..6 + + // Tue-Sun, excluding Fri + // Tue=2, Wed=3, Thu=4, Fri=5, Sat=6, Sun=0 + const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; + if (!allowedDay) return false; + + // Exclude holidays from ENV list + if (HOLIDAYS_SET.has(dateISO)) return false; + + return true; +} + +// ======================= TABLE ALLOCATION + LOCKS ======================= +function buildCandidates(area) { + const singles = TABLES + .filter((t) => t.area === area) + .map((t) => ({ + displayId: t.id, + locks: [t.id], + min: t.min, + max: t.max, + area: t.area, + notes: t.notes || "", + kind: "single", + })); + + const combos = TABLE_COMBINATIONS + .filter((c) => c.area === area) + .map((c) => ({ + displayId: c.displayId, + locks: c.replaces.slice(), + min: c.min, + max: c.max, + area: c.area, + notes: c.notes || "", + kind: "combo", + })); + + return singles.concat(combos); +} + +function parseLocksFromEvent(ev) { + const d = String(ev.description || ""); + const m = d.match(/LOCKS:\s*([A-Z0-9,]+)/i); + if (!m) return []; + return m[1].split(",").map((s) => s.trim()).filter(Boolean); +} + +async function getLockedTables(calendar, dateISO) { + const timeMin = `${dateISO}T00:00:00Z`; + const timeMax = `${dateISO}T23:59:59Z`; + + const resp = await calendar.events.list({ + calendarId: GOOGLE_CALENDAR_ID, + timeMin, + timeMax, + singleEvents: true, + orderBy: "startTime", + maxResults: 250, + }); + + const items = resp.data.items || []; + const locked = new Set(); + for (const ev of items) { + for (const t of parseLocksFromEvent(ev)) locked.add(t); + } + return locked; +} + +function allocateTable({ area, people, lockedSet }) { + const candidates = buildCandidates(area); + + let ok = candidates.filter((c) => people >= c.min && people <= c.max); + ok = ok.filter((c) => c.locks.every((t) => !lockedSet.has(t))); + + // Best fit: minimize wasted seats. Tie -> singles first. + ok.sort((a, b) => { + const wasteA = a.max - people; + const wasteB = b.max - people; + if (wasteA !== wasteB) return wasteA - wasteB; + if (a.kind !== b.kind) return a.kind === "single" ? -1 : 1; + return String(a.displayId).localeCompare(String(b.displayId)); + }); + + if (ok.length === 0) return null; + return ok[0]; +} + +// ======================= GOOGLE CALENDAR EVENT CREATION (IDEMPOTENT) ======================= async function createBookingEvent({ callSid, name, @@ -290,38 +593,28 @@ async function createBookingEvent({ people, phone, waTo, - - // accettiamo anche i nomi del debug endpoint - timeHHMM, - partySize, - notes, + area, + bookingType, + autoConfirm, + durationMinutes, + tableDisplayId, + tableLocks, + tableNotes, + specialRequestsRaw, + preorderLabel, + preorderPriceText, + outsideDisclaimer, + promoEligible, }) { - // Normalizzazione nomi - if (people == null && partySize != null) people = partySize; - if (!time24 && timeHHMM) time24 = timeHHMM; - - // Guardrails - if (!name) throw new Error("createBookingEvent: name mancante"); - if (!dateISO || !/^\d{4}-\d{2}-\d{2}$/.test(dateISO)) { - throw new Error(`createBookingEvent: dateISO non valido: ${dateISO}`); - } - if (!time24 || !/^\d{2}:\d{2}$/.test(time24)) { - throw new Error(`createBookingEvent: time24 non valido: ${time24}`); - } - - const peopleNum = Number(people); - if (!Number.isFinite(peopleNum) || peopleNum <= 0) { - throw new Error(`createBookingEvent: people non valido: ${people}`); - } - const calendar = getCalendarClient(); - if (!calendar) { - throw new Error("Google Calendar non configurato (manca JSON/JSON_B64 o CALENDAR_ID)."); - } + if (!calendar) throw new Error("Calendar client not configured"); + + const tz = GOOGLE_CALENDAR_TZ; + const { startLocal, endLocal } = computeStartEndLocal(dateISO, time24, durationMinutes); const privateKey = `callsid:${callSid || "no-callsid"}`; - // Idempotenza: se già creato per lo stesso callSid, non ricreare + // Idempotency: search existing event by privateKey in that date window const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -331,403 +624,650 @@ async function createBookingEvent({ maxResults: 10, }); - const found = (existing.data.items || []).find((ev) => - String(ev.description || "").includes(privateKey) - ); - + const found = (existing.data.items || []).find((ev) => String(ev.description || "").includes(privateKey)); if (found) { - return { eventId: found.id, htmlLink: found.htmlLink, reused: true }; + return { created: false, eventId: found.id, htmlLink: found.htmlLink }; } - // ============================ - // ✅ FIX FUSO ORARIO: - // inviamo a Google ORA LOCALE + timeZone Europe/Rome - // NIENTE "Z" e NIENTE toISOString() - // ============================ - const tz = GOOGLE_CALENDAR_TZ; + const prefix = autoConfirm ? "" : "DA CONFERMARE • "; + const summary = `${prefix}TB • ${tableDisplayId} • ${name} • ${people} pax`; + + const promoLine = + preorderLabel && preorderLabel.toLowerCase().includes("promo") + ? `Promo: ${promoEligible ? "Eleggibile (previa registrazione)" : "NON eleggibile (verificare con cliente)"}` + : null; + + const description = [ + `TABLE:${tableDisplayId}`, + `LOCKS:${(tableLocks || []).join(",")}`, + `AREA:${area}`, + `TYPE:${bookingType}`, + "", + `Nome: ${name}`, + `Persone: ${people}`, + phone ? `Telefono: ${phone}` : `Telefono: -`, + waTo ? `WhatsApp: ${waTo}` : `WhatsApp: -`, + tableNotes ? `Note tavolo: ${tableNotes}` : null, + specialRequestsRaw ? `Richieste: ${specialRequestsRaw}` : `Richieste: nessuna`, + preorderLabel ? `Preordine: ${preorderLabel}${preorderPriceText ? ` (${preorderPriceText})` : ""}` : null, + promoLine, + outsideDisclaimer ? `Nota esterno: ${outsideDisclaimer}` : null, + privateKey, + ] + .filter(Boolean) + .join("\n"); + + const requestBody = { + summary, + description, + start: { dateTime: startLocal, timeZone: tz }, + end: { dateTime: endLocal, timeZone: tz }, + }; + + const resp = await calendar.events.insert({ + calendarId: GOOGLE_CALENDAR_ID, + requestBody, + }); - const [hhStr, mmStr] = time24.split(":"); - const startH = Number(hhStr); - const startM = Number(mmStr); + return { created: true, eventId: resp.data.id, htmlLink: resp.data.htmlLink }; +} - if (!Number.isFinite(startH) || !Number.isFinite(startM) || startH < 0 || startH > 23 || startM < 0 || startM > 59) { - throw new Error(`createBookingEvent: time24 non valido: ${time24}`); +// ======================= WHATSAPP CONFIRMATION ======================= +async function sendWhatsAppConfirmation({ + waTo, + name, + dateISO, + time24, + people, + tableDisplayId, + area, + specialRequestsRaw, + preorderLabel, + preorderPriceText, + outsideDisclaimer, + bookingType, + promoEligible, +}) { + if (!twilioClient) throw new Error("Twilio client not configured"); + if (!TWILIO_WHATSAPP_FROM) throw new Error("Missing TWILIO_WHATSAPP_FROM"); + if (!waTo || !hasValidWaAddress(waTo)) throw new Error("Invalid WhatsApp address"); + + const lines = [ + `Ciao ${name}! Prenotazione registrata ✅`, + `Data: ${dateISO}`, + `Ora: ${time24}`, + `Persone: ${people}`, + `Tavolo: ${tableDisplayId} (${area === "inside" ? "interno" : "esterno"})`, + ]; + + if (bookingType === "drinks") { + lines.push(`Nota: a quest'orario la cucina potrebbe essere chiusa (solo drink e vino).`); } - // Calcola end locale (gestisce anche il passaggio al giorno dopo) - const duration = DEFAULT_EVENT_DURATION_MINUTES; - const startTotal = startH * 60 + startM; - const endTotal = startTotal + duration; + if (specialRequestsRaw && normalizeText(specialRequestsRaw) !== "nessuna") { + lines.push(`Richieste: ${specialRequestsRaw}`); + } else { + lines.push(`Richieste: nessuna`); + } - const endDayOffset = Math.floor(endTotal / (24 * 60)); // 0 o 1 (o più, ma improbabile) - const endMinutesOfDay = endTotal % (24 * 60); - const endH = Math.floor(endMinutesOfDay / 60); - const endM = endMinutesOfDay % 60; + if (preorderLabel) { + let preorderLine = `Preordine: ${preorderLabel}`; + if (preorderPriceText) preorderLine += ` (${preorderPriceText})`; + lines.push(preorderLine); - // dateISO + offset giorni se sfora mezzanotte - let endDateISO = dateISO; - if (endDayOffset > 0) { - const d = new Date(`${dateISO}T00:00:00`); - d.setDate(d.getDate() + endDayOffset); - endDateISO = toISODate(d); + if (preorderLabel.toLowerCase().includes("promo")) { + lines.push(`Promo: ${promoEligible ? "eleggibile previa registrazione" : "da verificare (giorno non promo o festivo)"}`); + } } - const startLocal = `${dateISO}T${String(startH).padStart(2, "0")}:${String(startM).padStart(2, "0")}:00`; - const endLocal = `${endDateISO}T${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}:00`; + if (outsideDisclaimer) { + lines.push(`Nota: ${outsideDisclaimer}`); + } - const requestBody = { - summary: `TuttiBrilli - ${name} - ${peopleNum} pax`, - description: [ - "Prenotazione", - `Nome: ${name}`, - `Persone: ${peopleNum}`, - `Telefono: ${phone || "-"}`, - `WhatsApp: ${waTo || "-"}`, - notes ? `Note: ${notes}` : null, - privateKey, - ] - .filter(Boolean) - .join("\n"), - start: { dateTime: startLocal, timeZone: tz }, - end: { dateTime: endLocal, timeZone: tz }, - }; + lines.push(`A presto da TuttiBrilli!`); - console.log("[CALENDAR] requestBody:", JSON.stringify(requestBody, null, 2)); + const body = lines.join("\n"); - try { - const resp = await calendar.events.insert({ - calendarId: GOOGLE_CALENDAR_ID, - requestBody, - }); + const msg = await twilioClient.messages.create({ + from: TWILIO_WHATSAPP_FROM, + to: waTo, + body, + }); - return { eventId: resp.data.id, htmlLink: resp.data.htmlLink, reused: false }; - } catch (err) { - console.error("[CALENDAR] insert failed", { - status: err?.code || err?.response?.status, - message: err?.message, - data: err?.response?.data, - }); - throw err; - } + return msg.sid; } -// -------------------- -// Twilio Voice - START -// -------------------- +// ======================= ROUTES ======================= +app.get("/health", (req, res) => res.json({ ok: true })); + app.post("/voice", async (req, res) => { - const respond = (xml) => res.type("text/xml").status(200).send(twiml(xml)); + const callSid = req.body.CallSid || ""; + const speech = req.body.SpeechResult || ""; + const session = getSession(callSid); + + const vr = buildTwiml(); try { - const callSid = req.body.CallSid || `local-${Date.now()}`; - const session = getSession(callSid); - - const speech = req.body.SpeechResult || ""; - const step = session.step || 1; - - // STEP 1: greeting + ask name - if (step === 1) { - session.step = 2; - sessions.set(callSid, session); - - return respond(` -${say("Ciao! Sono l'assistente di TuttiBrilli Enoteca. Per prenotare, dimmi il tuo nome e cognome.")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi nome e cognome." })} -${say("Non ho sentito nulla. Riprova.")} -${redirect(`${BASE_URL}/voice`)} -`); + if (!session) { + sayIt(vr, "Errore di sessione. Riprova tra poco."); + return res.type("text/xml").send(vr.toString()); } - // STEP 2: collect name - if (step === 2) { - if (!speech) { - incRetries(session); - if (session.retries >= 2) { - sessions.delete(callSid); - return respond(`${say("Non riesco a sentirti bene. Riprova più tardi.")}${hangup()}`); + const speechNorm = normalizeText(speech); + const emptySpeech = !speechNorm; + + switch (session.step) { + case 1: { + if (emptySpeech) { + resetRetries(session); + gatherSpeech(vr, "Ciao! Benvenuto da TuttiBrilli. Dimmi il tuo nome per la prenotazione."); + break; } - sessions.set(callSid, session); - return respond(` -${say("Non ho capito. Dimmi nome e cognome.")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi nome e cognome." })} -`); + + session.name = speech.trim().slice(0, 60); + resetRetries(session); + session.step = 2; + gatherSpeech(vr, `Perfetto ${session.name}. Per quale data vuoi prenotare? Puoi dire per esempio domani o 30 dicembre.`); + break; } - session.name = speech.trim(); - session.step = 3; - resetRetries(session); - sessions.set(callSid, session); + case 2: { + if (emptySpeech) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non ho capito la data. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + gatherSpeech(vr, "Non ho sentito la data. Dimmi la data della prenotazione."); + break; + } + + const dateISO = parseDateIT(speech); + if (!dateISO) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non riesco a capire la data. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + gatherSpeech(vr, "Scusa, non ho capito. Dimmi la data, ad esempio: 30 12 2025, oppure domani."); + break; + } - return respond(` -${say(`Perfetto ${session.name}. Per quale giorno vuoi prenotare? Puoi dire oggi, domani, oppure una data.`)} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi la data della prenotazione." })} -`); - } + session.dateISO = dateISO; + resetRetries(session); + session.step = 3; + gatherSpeech(vr, "A che ora? Ad esempio: 20 e 30."); + break; + } + + case 3: { + if (emptySpeech) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non ho capito l'orario. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + gatherSpeech(vr, "Non ho sentito l'orario. Dimmi a che ora vuoi prenotare."); + break; + } + + const time24 = parseTimeIT(speech); + if (!time24) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non riesco a capire l'orario. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + gatherSpeech(vr, "Scusa, non ho capito. Dimmi l'orario, ad esempio 20 e 30."); + break; + } - // STEP 3: date - if (step === 3) { - const dateISO = parseDateIT_MVP(speech); - if (!dateISO) { - incRetries(session); - if (session.retries >= 2) { + session.time24 = time24; + + const calendar = getCalendarClient(); + if (!calendar) throw new Error("Calendar client not configured"); + + // HARD BLOCK: "locale chiuso" on that date -> no bookings + const isClosed = await calendarHasMarkerOnDate(calendar, session.dateISO, "locale chiuso"); + if (isClosed) { + sayIt(vr, "Mi dispiace, per quella data il locale risulta chiuso e non posso fissare prenotazioni. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Grazie."); + vr.hangup(); sessions.delete(callSid); - return respond(`${say("Non sono riuscito a capire la data. Riprova più tardi.")}${hangup()}`); + break; + } + + // Promo eligibility: by day + not "no promo" marker on calendar + const dayOk = isPromoEligibleByDay(session.dateISO); + const hasNoPromo = await calendarHasMarkerOnDate(calendar, session.dateISO, "no promo"); + session.promoEligible = dayOk && !hasNoPromo; + + // Validate opening hours / booking type + const { bookingType, autoConfirm, reason } = deriveBookingTypeAndConfirm(session.dateISO, session.time24); + session.bookingType = bookingType; + session.autoConfirm = autoConfirm; + + if (bookingType === "closed") { + sayIt(vr, `A quell'orario siamo chiusi. ${reason ? reason : ""} Ti passo un operatore.`); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare durante l'orario di apertura. Grazie."); + break; + } + + if (bookingType === "operator") { + sayIt(vr, "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo."); + } else if (bookingType === "drinks") { + sayIt(vr, "Nota: a quest'orario la cucina potrebbe essere chiusa. Possiamo fare solo drink e vino."); } - sessions.set(callSid, session); - return respond(` -${say("Non ho capito la data. Puoi dire per esempio: domani, oppure 27 12 2025.")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi la data." })} -`); + + resetRetries(session); + session.step = 4; + gatherSpeech(vr, "Per quante persone?"); + break; } - session.dateISO = dateISO; - session.step = 4; - resetRetries(session); - sessions.set(callSid, session); + case 4: { + if (emptySpeech) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non ho capito il numero di persone. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + gatherSpeech(vr, "Non ho sentito. Per quante persone?"); + break; + } - return respond(` -${say("A che ora? Dimmi un orario, per esempio venti e trenta.")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi l'orario." })} -`); - } + const people = parsePeopleIT(speech); + if (!people || people < 1 || people > 18) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non riesco a gestire questa prenotazione automaticamente. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Grazie."); + break; + } + gatherSpeech(vr, "Scusa, quante persone? Dimmi un numero tra 1 e 18."); + break; + } - // STEP 4: time - if (step === 4) { - const time24 = parseTimeIT_MVP(speech); - if (!time24) { - incRetries(session); - if (session.retries >= 2) { - sessions.delete(callSid); - return respond(`${say("Non sono riuscito a capire l'orario. Riprova più tardi.")}${hangup()}`); + session.people = people; + resetRetries(session); + session.step = 5; + gatherSpeech( + vr, + "Ci sono allergie, intolleranze o richieste particolari? Puoi dire per esempio: nessuna, celiaco, senza lattosio, vegetariano, tavolo tranquillo." + ); + break; + } + + case 5: { + if (emptySpeech) { + if (bumpRetries(session) > 2) { + session.specialRequestsRaw = "nessuna"; + resetRetries(session); + session.step = 6; + gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); + break; + } + gatherSpeech(vr, "Non ho sentito. Ci sono allergie o richieste particolari? Se non ce ne sono, di' nessuna."); + break; } - sessions.set(callSid, session); - return respond(` -${say("Non ho capito l'orario. Puoi dire per esempio: venti e trenta.")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi l'orario." })} -`); + + session.specialRequestsRaw = speech.trim().slice(0, 200); + resetRetries(session); + session.step = 6; + gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); + break; } - session.time24 = time24; - session.step = 5; - resetRetries(session); - sessions.set(callSid, session); + case 6: { + // Preorder choice (single selection from fixed menu) + if (emptySpeech) { + if (bumpRetries(session) > 2) { + session.preorderChoiceKey = null; + session.preorderLabel = null; + resetRetries(session); + session.step = 8; + gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + break; + } + gatherSpeech(vr, "Non ho sentito. Vuoi preordinare? Di' cena, apericena, dopocena, piatto apericena, piatto apericena promo, oppure nessuno."); + break; + } - return respond(` -${say("Per quante persone?")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di persone." })} -`); - } + const key = parsePreorderChoiceKey(speech); + + if (key === "unknown") { + if (bumpRetries(session) > 2) { + session.preorderChoiceKey = null; + session.preorderLabel = null; + resetRetries(session); + session.step = 8; + gatherSpeech(vr, "Ok, nessun preordine. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + break; + } + gatherSpeech(vr, "Scusa, non ho capito. Puoi dire: cena, apericena, dopocena, piatto apericena, piatto apericena promo, oppure nessuno."); + break; + } - // STEP 5: people - if (step === 5) { - const ppl = parsePeopleIT_MVP(speech); - if (!ppl) { - incRetries(session); - if (session.retries >= 2) { - sessions.delete(callSid); - return respond(`${say("Non sono riuscito a capire il numero di persone. Riprova più tardi.")}${hangup()}`); + // "nessuno" + if (!key) { + session.preorderChoiceKey = null; + session.preorderLabel = null; + resetRetries(session); + session.step = 8; + gatherSpeech(vr, "Perfetto. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + break; + } + + // validate constraints + const opt = getPreorderOptionByKey(key); + if (!opt) { + session.preorderChoiceKey = null; + session.preorderLabel = null; + resetRetries(session); + session.step = 8; + gatherSpeech(vr, "Ok. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + break; + } + + // dopocena only after 22:30 + if (opt.constraints && opt.constraints.minTime) { + if (!isTimeAtOrAfter(session.time24, opt.constraints.minTime)) { + sayIt(vr, "Il dopocena è disponibile solo dopo le 22 e 30."); + resetRetries(session); + gatherSpeech(vr, "Vuoi scegliere tra cena, apericena o piatto apericena? Oppure dì nessuno."); + break; + } } - sessions.set(callSid, session); - return respond(` -${say("Non ho capito. Dimmi il numero di persone, per esempio due o quattro.")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di persone." })} -`); + + // promo rule check: we allow selection, but if not eligible we warn & still record + if (opt.constraints && opt.constraints.promoOnly) { + if (!session.promoEligible) { + sayIt(vr, "Nota: oggi la promo potrebbe non essere valida, per giorno non promo, festivo o indicazione no promo. La segnalo comunque e verrà verificata."); + } + } + + session.preorderChoiceKey = opt.key; + session.preorderLabel = opt.label; + + resetRetries(session); + session.step = 8; + gatherSpeech(vr, "Perfetto. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + break; } - session.people = ppl; - session.step = 6; - resetRetries(session); - sessions.set(callSid, session); + case 8: { + // Area selection with outside disclaimer + recommendation + if (emptySpeech) { + if (bumpRetries(session) > 2) { + session.area = "inside"; + resetRetries(session); + session.step = 10; + gatherSpeech(vr, "Perfetto. Dimmi il tuo numero di telefono, anche per WhatsApp."); + break; + } + gatherSpeech(vr, "Non ho capito. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + break; + } + + if (session.pendingOutsideConfirm) { + const area = parseAreaIT(speech); + const t = normalizeText(speech); + + if (area === "inside") { + session.area = "inside"; + session.pendingOutsideConfirm = false; + resetRetries(session); + session.step = 10; + gatherSpeech(vr, "Perfetto, interno. Dimmi il tuo numero di telefono, anche per WhatsApp."); + break; + } + if (area === "outside" || t.includes("confermo") || t.includes("va bene esterno")) { + session.area = "outside"; + session.pendingOutsideConfirm = false; + resetRetries(session); + session.step = 10; + gatherSpeech(vr, "Perfetto, esterno. Dimmi il tuo numero di telefono, anche per WhatsApp."); + break; + } + + if (bumpRetries(session) > 2) { + session.area = "inside"; + session.pendingOutsideConfirm = false; + resetRetries(session); + session.step = 10; + gatherSpeech(vr, "Ok, ti assegno un tavolo interno. Dimmi il tuo numero di telefono."); + break; + } + + gatherSpeech(vr, "Preferisci interno, oppure confermi esterno?"); + break; + } - return respond(` -${say("Perfetto. Dimmi il tuo numero di telefono, così possiamo confermare su WhatsApp.")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di telefono." })} -`); - } + const area = parseAreaIT(speech); + if (!area) { + if (bumpRetries(session) > 2) { + session.area = "inside"; + resetRetries(session); + session.step = 10; + gatherSpeech(vr, "Ok, ti assegno un tavolo interno. Dimmi il tuo numero di telefono."); + break; + } + gatherSpeech(vr, "Scusa, non ho capito. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + break; + } + + if (area === "outside") { + session.pendingOutsideConfirm = true; + resetRetries(session); + gatherSpeech( + vr, + "Ti avviso che all'esterno non ci sono riscaldamenti né copertura, e in caso di maltempo non è garantito il posto all'interno. Ti consiglio l'interno. Preferisci interno, oppure confermi esterno?" + ); + break; + } - // STEP 6: phone / whatsapp - if (step === 6) { - let phone = String(speech || "").replace(/[^\d+]/g, ""); - if (phone && !phone.startsWith("+")) { - if (phone.length >= 9 && phone.length <= 11) phone = `+39${phone}`; + session.area = "inside"; + resetRetries(session); + session.step = 10; + gatherSpeech(vr, "Perfetto, interno. Dimmi il tuo numero di telefono, anche per WhatsApp."); + break; } - if (!isValidPhoneE164(phone)) { - incRetries(session); - if (session.retries >= 2) { + case 10: { + // Phone / WhatsApp + if (emptySpeech) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non ho capito il numero. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + gatherSpeech(vr, "Non ho sentito il numero. Dimmi il tuo numero di telefono, per WhatsApp."); + break; + } + + const phone = extractPhoneFromSpeech(speech); + if (!phone || !isValidPhoneE164(phone)) { + if (bumpRetries(session) > 2) { + sayIt(vr, "Non riesco a capire il numero. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Grazie."); + break; + } + gatherSpeech(vr, "Scusa, non ho capito. Dimmi il numero in questo formato: più trentanove, e poi il numero."); + break; + } + + session.phone = phone; + session.waTo = `whatsapp:${phone}`; + resetRetries(session); + + // ===== ALLOCATE TABLE + CALENDAR LOCK ===== + const calendar = getCalendarClient(); + if (!calendar) throw new Error("Calendar client not configured"); + + // Safety: re-check "locale chiuso" before committing + const isClosed = await calendarHasMarkerOnDate(calendar, session.dateISO, "locale chiuso"); + if (isClosed) { + sayIt(vr, "Mi dispiace, per quella data il locale risulta chiuso e non posso fissare prenotazioni. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Grazie."); + vr.hangup(); sessions.delete(callSid); - return respond(`${say("Non sono riuscito a capire il numero. Riprova più tardi.")}${hangup()}`); + break; } - sessions.set(callSid, session); - return respond(` -${say("Non ho capito il numero. Puoi dirlo lentamente, oppure includere il prefisso, per esempio più trentanove...")} -${gatherSpeech({ action: `${BASE_URL}/voice`, prompt: "Dimmi il numero di telefono." })} -`); - } - session.phone = phone; - session.waTo = `whatsapp:${phone}`; - session.step = 7; - resetRetries(session); - sessions.set(callSid, session); - - return respond(` -${say("Perfetto. Sto registrando la prenotazione.")} -${pause(1)} -${redirect(`${BASE_URL}/voice`)} -`); - } + const d = new Date(`${session.dateISO}T00:00:00`); + session.durationMinutes = getDurationMinutes(session.people, d); - // STEP 7: crea evento su Calendar + invia WhatsApp (SOLO SE CALENDAR OK) - if (session.step === 7) { - let calendarResult = null; - - // 1) Google Calendar (DEVE andare a buon fine) - try { - calendarResult = await createBookingEvent({ - callSid, - name: session.name, - dateISO: session.dateISO, - time24: session.time24, + const lockedSet = await getLockedTables(calendar, session.dateISO); + const chosen = allocateTable({ + area: session.area, people: session.people, - phone: session.phone, - waTo: session.waTo, - }); - } catch (e) { - console.error("[VOICE] createBookingEvent failed:", { - message: e?.message, - status: e?.code || e?.response?.status, - data: e?.response?.data, + lockedSet, }); - sessions.delete(callSid); - return respond(` -${say("Ho avuto un problema tecnico nel registrare la prenotazione in calendario. Riprova tra poco oppure scrivici su WhatsApp.")} -${hangup()} - `); - } + if (!chosen) { + sayIt(vr, "Mi dispiace, per quell'orario non ho tavoli disponibili nella sala scelta. Ti passo un operatore per trovare una soluzione."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi provare un altro orario. Grazie."); + break; + } - if (!calendarResult) { - console.error("[VOICE] createBookingEvent returned null/undefined"); - sessions.delete(callSid); - return respond(` -${say("Non sono riuscito a registrare la prenotazione in calendario. Riprova tra poco oppure scrivici su WhatsApp.")} -${hangup()} - `); - } + session.tableDisplayId = chosen.displayId; + session.tableLocks = chosen.locks; + session.tableNotes = chosen.notes || null; - // 2) WhatsApp (SOLO DOPO Calendar OK) - try { - const waTo = session.waTo; - const waBody = - `Ciao ${session.name}! Prenotazione registrata ✅\n` + - `Data: ${session.dateISO}\nOra: ${session.time24}\nPersone: ${session.people}\n` + - `A presto da TuttiBrilli!`; - - if (!twilioClient) { - console.error("[WA] Twilio client non configurato: manca TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN"); - } else if (!TWILIO_WHATSAPP_FROM) { - console.error("[WA] TWILIO_WHATSAPP_FROM mancante:", TWILIO_WHATSAPP_FROM); - } else if (!waTo || !hasValidWaAddress(waTo)) { - console.error("[WA] waTo non valido:", waTo); - } else { - await twilioClient.messages.create({ - from: TWILIO_WHATSAPP_FROM, - to: waTo, - body: waBody, + const outsideDisclaimer = + session.area === "outside" + ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." + : null; + + // Preorder price text (if any) + let preorderPriceText = null; + if (session.preorderChoiceKey) { + const opt = getPreorderOptionByKey(session.preorderChoiceKey); + if (opt && typeof opt.priceEUR === "number") preorderPriceText = `${opt.priceEUR} €`; + } + + // Create Calendar Event (idempotent) + let calResult; + try { + calResult = await createBookingEvent({ + callSid, + name: session.name, + dateISO: session.dateISO, + time24: session.time24, + people: session.people, + phone: session.phone, + waTo: session.waTo, + area: session.area, + bookingType: session.bookingType, + autoConfirm: session.autoConfirm, + durationMinutes: session.durationMinutes, + tableDisplayId: session.tableDisplayId, + tableLocks: session.tableLocks, + tableNotes: session.tableNotes, + specialRequestsRaw: session.specialRequestsRaw, + preorderLabel: session.preorderLabel, + preorderPriceText, + outsideDisclaimer, + promoEligible: Boolean(session.promoEligible), }); + } catch (e) { + console.error("[CALENDAR] create error:", e); + sayIt(vr, "Mi dispiace, c'è stato un problema nel registrare la prenotazione. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + + // WhatsApp ONLY if autoConfirm is true AND calendar ok + if (session.autoConfirm) { + try { + await sendWhatsAppConfirmation({ + waTo: session.waTo, + name: session.name, + dateISO: session.dateISO, + time24: session.time24, + people: session.people, + tableDisplayId: session.tableDisplayId, + area: session.area, + specialRequestsRaw: session.specialRequestsRaw, + preorderLabel: session.preorderLabel, + preorderPriceText, + outsideDisclaimer, + bookingType: session.bookingType, + promoEligible: Boolean(session.promoEligible), + }); + } catch (e) { + console.error("[WHATSAPP] send error:", e); + // Don't fail the call: calendar is created already. + } + } + + // Final voice confirmation + if (session.autoConfirm) { + let extra = ""; + if (session.area === "outside") { + extra = " Ti ricordo che all'esterno in caso di maltempo non è garantito il posto dentro."; + } + + let preorderVoice = ""; + if (session.preorderLabel) { + preorderVoice = ` Ho segnato il preordine: ${session.preorderLabel}.`; + } + + sayIt( + vr, + `Perfetto ${session.name}. Ho registrato la prenotazione per ${session.people} persone il ${session.dateISO} alle ${session.time24}, tavolo ${session.tableDisplayId}.${preorderVoice}${extra} Ti ho inviato conferma su WhatsApp. A presto!` + ); + } else { + sayIt( + vr, + `Perfetto ${session.name}. Ho registrato la richiesta. Un operatore la confermerà appena possibile. Grazie e a presto!` + ); } - } catch (e) { - console.error("[WA] WhatsApp send failed:", e); - // Non blocchiamo: evento già creato + + vr.hangup(); + sessions.delete(callSid); + break; } - sessions.delete(callSid); - return respond(` -${say("Perfetto! Ho registrato la prenotazione e ti ho inviato un WhatsApp di conferma. A presto da TuttiBrilli!")} -${hangup()} - `); + default: { + sayIt(vr, "Grazie. A presto!"); + vr.hangup(); + sessions.delete(callSid); + break; + } } - // fallback - sessions.delete(callSid); - return respond(` -${say("Ripartiamo da capo.")} -${redirect(`${BASE_URL}/voice`)} -`); + return res.type("text/xml").send(vr.toString()); } catch (err) { - console.error("[VOICE] FLOW ERROR:", err); - + console.error("[VOICE] Error:", err); + sayIt(vr, "Mi dispiace, c'è stato un errore tecnico. Riprova tra poco."); if (canForwardToHuman()) { - return res.type("text/xml").status(200).send(twiml(forwardToHumanTwiml())); + sayIt(vr, "Ti passo un operatore."); + vr.dial({}, HUMAN_FORWARD_TO); + } else { + vr.hangup(); } - - return res.type("text/xml").status(200).send( - twiml(` -${say("C'è stato un problema tecnico. Riprova tra poco.")} -${hangup()} -`) - ); + return res.type("text/xml").send(vr.toString()); } }); -// -------------------- -// DEBUG CALENDAR TEST -// -------------------- -app.get("/debug/calendar-test", async (req, res) => { - try { - console.log("[DEBUG] calendar-test called"); - - const dateISO = String(req.query.dateISO || new Date().toISOString().slice(0, 10)); - const timeHHMM = String(req.query.timeHHMM || "20:30"); - - const result = await createBookingEvent({ - callSid: "DEBUG-CALLSID", - name: "TEST TuttiBrilli", - phone: "+391234567890", - dateISO, - timeHHMM, // verrà mappato su time24 - partySize: 2, // verrà mappato su people - waTo: "whatsapp:+391234567890", - notes: "Evento di test creato da /debug/calendar-test", - }); - - return res.json({ ok: true, ...result }); - } catch (err) { - console.error("[DEBUG] calendar-test error:", { - message: err?.message, - status: err?.code || err?.response?.status, - data: err?.response?.data, - }); - - return res.status(500).json({ - ok: false, - message: err?.message, - status: err?.code || err?.response?.status, - data: err?.response?.data, - }); - } +app.get("/", (req, res) => { + res.send("TuttiBrilli Voice Booking is running. Use POST /voice from Twilio."); }); -// -------------------- -// START SERVER -// -------------------- app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - console.log(`BASE_URL: ${BASE_URL || "(non impostato)"}`); - - const raw = getServiceAccountJsonRaw(); - console.log(`Calendar configured: ${Boolean(raw && GOOGLE_CALENDAR_ID)}`); - console.log(`Calendar TZ: ${GOOGLE_CALENDAR_TZ}`); - console.log(`Twilio configured: ${Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN)}`); - - console.log( - `Forwarding enabled: ${ENABLE_FORWARDING} | HUMAN_FORWARD_TO: ${HUMAN_FORWARD_TO || "(non impostato)"}` - ); - - if (raw && !GOOGLE_SERVICE_ACCOUNT_JSON_B64) { - console.log(`GOOGLE_SERVICE_ACCOUNT_JSON length: ${(GOOGLE_SERVICE_ACCOUNT_JSON || "").length}`); - } - if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { - console.log(`GOOGLE_SERVICE_ACCOUNT_JSON_B64 length: ${GOOGLE_SERVICE_ACCOUNT_JSON_B64.length}`); - } + console.log(`[BOOT] Listening on port ${PORT}`); + console.log(`[BOOT] BASE_URL=${BASE_URL}`); }); From e27ba85e09b6f87e8550d84b4f49a0a45a0c66c8 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 02:01:18 +0100 Subject: [PATCH 030/143] Update app.js --- app.js | 376 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 182 insertions(+), 194 deletions(-) diff --git a/app.js b/app.js index 8283ebfdf1..ce85d172bf 100644 --- a/app.js +++ b/app.js @@ -2,23 +2,42 @@ /** * TuttiBrilli Enoteca - Voice Booking Assistant (Single file, copy/paste) + * + * STACK: * - Twilio Voice (Speech Gather) -> Node/Express - * - Google Calendar (service account) for booking events + * - Google Calendar API (service account) for booking events * - Twilio WhatsApp confirmation ONLY after calendar success * - * NEW FEATURES: - * - Block bookings if Calendar has an event containing "locale chiuso" (summary/description) on that date - * - Preorder structured menu options: - * - cena - * - apericena - * - dopocena (only after 22:30) - * - Piatto Apericena (25€) - * - Piatto Apericena Promo (eligible only if: Tue-Sun, NOT Fri, NOT holidays, and no "no promo" marker on calendar date) - * - Always record allergies/intolerances/special requests in Calendar + WhatsApp + * IMPORTANT FIX (vs previous version): + * ✅ NO Google Calendar calls during early steps (name/date/time/etc.) + * Calendar is called ONLY at the final step (after phone) to: + * - block "locale chiuso" + * - detect "no promo" + * - lock tables (avoid double-booking) + * - create the booking event + * - send WhatsApp (only if Calendar succeeded and autoConfirm=true) + * + * FEATURES: + * - Opening hours rules (restaurant/drinks/operator on Mondays) + * - Table durations (+30min Wed/Fri music nights for <=8 pax) + * - Table map (inside/outside) + combinations/unions + * - Always ask inside/outside; if outside => warn + recommend inside + confirm + * - Menu preorder (fixed options): + * - cena + * - apericena + * - dopocena (only after 22:30) + * - Piatto Apericena (25€) + * - Piatto Apericena in promo (eligibility rules + "no promo" marker) + * - Promo rules: + * - Eligible Tue-Sun excluding Fri + * - Exclude holidays listed in ENV HOLIDAYS_YYYY_MM_DD + * - Exclude if Calendar has marker "no promo" on that date + * - Block all bookings if Calendar has marker "locale chiuso" on that date + * - Store allergies/intolerances/special requests in Calendar + WhatsApp * * ENV REQUIRED: * PORT - * BASE_URL (e.g. https://your-service.onrender.com) <-- IMPORTANT for Twilio action URL + * BASE_URL (e.g. https://your-service.onrender.com) <-- required for Twilio gather action URL * * TWILIO_ACCOUNT_SID * TWILIO_AUTH_TOKEN @@ -32,7 +51,7 @@ * ENABLE_FORWARDING=true/false * HUMAN_FORWARD_TO=+39... * - * HOLIDAYS_YYYY_MM_DD="2025-01-01,2025-04-20,2025-12-25" // for promo exclusion on holidays + * HOLIDAYS_YYYY_MM_DD="2025-01-01,2025-04-20,2025-12-25" // promo exclusion on holidays */ const express = require("express"); @@ -60,7 +79,6 @@ const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "false").toLowerCase const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; const HOLIDAYS_YYYY_MM_DD = process.env.HOLIDAYS_YYYY_MM_DD || ""; - const HOLIDAYS_SET = new Set( HOLIDAYS_YYYY_MM_DD .split(",") @@ -87,12 +105,12 @@ const OPENING = { const PREORDER_OPTIONS = [ { key: "cena", label: "Cena", priceEUR: null, constraints: {} }, { key: "apericena", label: "Apericena", priceEUR: null, constraints: {} }, - { key: "dopocena", label: "Dopocena", priceEUR: null, constraints: { minTime: "22:30" } }, // After 22:30 + { key: "dopocena", label: "Dopocena (dopo le 22:30)", priceEUR: null, constraints: { minTime: "22:30" } }, { key: "piatto_apericena", label: "Piatto Apericena", priceEUR: 25, constraints: {} }, { key: "piatto_apericena_promo", label: "Piatto Apericena in promo (previa registrazione)", - priceEUR: null, // promo price not specified + priceEUR: null, constraints: { promoOnly: true }, }, ]; @@ -159,12 +177,9 @@ function getSession(callSid) { specialRequestsRaw: null, - preorderOptIn: null, preorderChoiceKey: null, preorderLabel: null, - promoEligible: null, // computed from calendar/day rules - // area area: null, // inside/outside pendingOutsideConfirm: false, @@ -179,8 +194,11 @@ function getSession(callSid) { // derived durationMinutes: null, - bookingType: "restaurant", // or drinks/operator + bookingType: "restaurant", // restaurant/drinks/operator autoConfirm: true, + + // computed only at final step (after Calendar checks) + promoEligible: null, }); } return sessions.get(callSid); @@ -196,10 +214,7 @@ function resetRetries(session) { // ======================= TEXT / PARSERS ======================= function normalizeText(s) { - return String(s || "") - .trim() - .toLowerCase() - .replace(/\s+/g, " "); + return String(s || "").trim().toLowerCase().replace(/\s+/g, " "); } function toISODate(d) { @@ -256,9 +271,7 @@ function parseTimeIT(speech) { const onlyH = t.match(/\b(\d{1,2})\b/); if (onlyH) { const hh = Number(onlyH[1]); - if (hh >= 0 && hh <= 23) { - return `${String(hh).padStart(2, "0")}:00`; - } + if (hh >= 0 && hh <= 23) return `${String(hh).padStart(2, "0")}:00`; } return null; @@ -273,18 +286,35 @@ function parsePeopleIT(speech) { return n; } -function parseYesNoIT(speech) { +function parseAreaIT(speech) { const t = normalizeText(speech); - if (!t) return null; - if (t.includes("si") || t.includes("sì") || t.includes("certo") || t.includes("ok") || t.includes("va bene")) return true; - if (t.includes("no") || t.includes("non") || t.includes("niente") || t.includes("nessun")) return false; + if (t.includes("intern") || t.includes("dentro") || t.includes("sala")) return "inside"; + if (t.includes("estern") || t.includes("fuori") || t.includes("terraz") || t.includes("giardino")) return "outside"; return null; } -function parseAreaIT(speech) { +function parsePreorderChoiceKey(speech) { const t = normalizeText(speech); - if (t.includes("intern") || t.includes("dentro") || t.includes("sala")) return "inside"; - if (t.includes("estern") || t.includes("fuori") || t.includes("terraz") || t.includes("giardino")) return "outside"; + + if (t.includes("promo")) return "piatto_apericena_promo"; + if (t.includes("piatto") && t.includes("apericena")) return "piatto_apericena"; + if (t.includes("dopocena") || t.includes("dopo cena") || t.includes("dopo")) return "dopocena"; + if (t.includes("apericena")) return "apericena"; + if (t.includes("cena")) return "cena"; + if (t.includes("nessuno") || t.includes("nessuna") || t.includes("no")) return null; + + return "unknown"; +} + +function getPreorderOptionByKey(key) { + return PREORDER_OPTIONS.find((o) => o.key === key) || null; +} + +function parseYesNoIT(speech) { + const t = normalizeText(speech); + if (!t) return null; + if (t.includes("si") || t.includes("sì") || t.includes("certo") || t.includes("ok") || t.includes("va bene")) return true; + if (t === "no" || t.includes("no") || t.includes("non") || t.includes("niente") || t.includes("nessun")) return false; return null; } @@ -304,25 +334,7 @@ function extractPhoneFromSpeech(speech) { return null; } -// Preorder parsing: understand fixed options by keywords -function parsePreorderChoiceKey(speech) { - const t = normalizeText(speech); - - // Strong matches first - if (t.includes("promo")) return "piatto_apericena_promo"; - if (t.includes("piatto") && t.includes("apericena")) return "piatto_apericena"; - if (t.includes("dopo") || t.includes("dopocena")) return "dopocena"; - if (t.includes("apericena")) return "apericena"; - if (t.includes("cena")) return "cena"; - if (t.includes("nessuno") || t.includes("nessuna") || t.includes("no")) return null; - - return "unknown"; -} - -function getPreorderOptionByKey(key) { - return PREORDER_OPTIONS.find((o) => o.key === key) || null; -} - +// ======================= TIME HELPERS ======================= function hmToMinutes(hm) { const [h, m] = String(hm).split(":").map(Number); return h * 60 + m; @@ -332,7 +344,6 @@ function isTimeAtOrAfter(time24, minTime24) { return hmToMinutes(time24) >= hmToMinutes(minTime24); } -// ======================= TIME / OPENING VALIDATION ======================= function getRestaurantWindowForDay(day) { if (day === 5 || day === 6) return OPENING.restaurant.friSat; // Fri, Sat return OPENING.restaurant.default; @@ -345,6 +356,22 @@ function isWithinWindow(time24, startHM, endHM) { return t >= start && t <= end; } +function deriveBookingTypeAndConfirm(dateISO, time24) { + const d = new Date(`${dateISO}T00:00:00`); + const day = d.getDay(); + + if (day === OPENING.closedDay) return { bookingType: "operator", autoConfirm: false, reason: "Lunedì chiuso" }; + + const restWin = getRestaurantWindowForDay(day); + const inRestaurant = isWithinWindow(time24, restWin.start, restWin.end); + const inDrinks = isWithinWindow(time24, OPENING.drinksOnly.start, OPENING.drinksOnly.end); + + if (inRestaurant) return { bookingType: "restaurant", autoConfirm: true, reason: null }; + if (inDrinks) return { bookingType: "drinks", autoConfirm: true, reason: "Fuori orario cucina" }; + + return { bookingType: "closed", autoConfirm: false, reason: "Fuori orario" }; +} + function getDurationMinutes(people, dateObj) { let minutes; if (people <= 4) minutes = 120; @@ -381,24 +408,6 @@ function computeStartEndLocal(dateISO, time24, durationMinutes) { return { startLocal, endLocal }; } -function deriveBookingTypeAndConfirm(dateISO, time24) { - const d = new Date(`${dateISO}T00:00:00`); - const day = d.getDay(); - - if (day === OPENING.closedDay) { - return { bookingType: "operator", autoConfirm: false, reason: "Lunedì chiuso" }; - } - - const restWin = getRestaurantWindowForDay(day); - const inRestaurant = isWithinWindow(time24, restWin.start, restWin.end); - const inDrinks = isWithinWindow(time24, OPENING.drinksOnly.start, OPENING.drinksOnly.end); - - if (inRestaurant) return { bookingType: "restaurant", autoConfirm: true, reason: null }; - if (inDrinks) return { bookingType: "drinks", autoConfirm: true, reason: "Fuori orario cucina" }; - - return { bookingType: "closed", autoConfirm: false, reason: "Fuori orario" }; -} - // ======================= TWILIO TWIML HELPERS ======================= function buildTwiml() { return new twilio.twiml.VoiceResponse(); @@ -472,7 +481,7 @@ function getCalendarClient() { return google.calendar({ version: "v3", auth }); } -// ======================= CALENDAR MARKERS (locale chiuso / no promo) ======================= +// ======================= CALENDAR HELPERS (markers / locks / idempotency) ======================= function containsMarker(ev, markerLower) { const s = `${String(ev.summary || "")}\n${String(ev.description || "")}`.toLowerCase(); return s.includes(markerLower); @@ -495,20 +504,42 @@ async function calendarHasMarkerOnDate(calendar, dateISO, markerLower) { function isPromoEligibleByDay(dateISO) { const d = new Date(`${dateISO}T00:00:00`); - const day = d.getDay(); // 0..6 - - // Tue-Sun, excluding Fri - // Tue=2, Wed=3, Thu=4, Fri=5, Sat=6, Sun=0 + const day = d.getDay(); + // Tue-Sun excluding Fri (Tue=2, Wed=3, Thu=4, Sat=6, Sun=0) const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; if (!allowedDay) return false; - - // Exclude holidays from ENV list if (HOLIDAYS_SET.has(dateISO)) return false; - return true; } -// ======================= TABLE ALLOCATION + LOCKS ======================= +function parseLocksFromEvent(ev) { + const d = String(ev.description || ""); + const m = d.match(/LOCKS:\s*([A-Z0-9,]+)/i); + if (!m) return []; + return m[1].split(",").map((s) => s.trim()).filter(Boolean); +} + +async function getLockedTables(calendar, dateISO) { + const timeMin = `${dateISO}T00:00:00Z`; + const timeMax = `${dateISO}T23:59:59Z`; + + const resp = await calendar.events.list({ + calendarId: GOOGLE_CALENDAR_ID, + timeMin, + timeMax, + singleEvents: true, + orderBy: "startTime", + maxResults: 250, + }); + + const locked = new Set(); + for (const ev of resp.data.items || []) { + for (const t of parseLocksFromEvent(ev)) locked.add(t); + } + return locked; +} + +// ======================= TABLE ALLOCATION ======================= function buildCandidates(area) { const singles = TABLES .filter((t) => t.area === area) @@ -537,41 +568,12 @@ function buildCandidates(area) { return singles.concat(combos); } -function parseLocksFromEvent(ev) { - const d = String(ev.description || ""); - const m = d.match(/LOCKS:\s*([A-Z0-9,]+)/i); - if (!m) return []; - return m[1].split(",").map((s) => s.trim()).filter(Boolean); -} - -async function getLockedTables(calendar, dateISO) { - const timeMin = `${dateISO}T00:00:00Z`; - const timeMax = `${dateISO}T23:59:59Z`; - - const resp = await calendar.events.list({ - calendarId: GOOGLE_CALENDAR_ID, - timeMin, - timeMax, - singleEvents: true, - orderBy: "startTime", - maxResults: 250, - }); - - const items = resp.data.items || []; - const locked = new Set(); - for (const ev of items) { - for (const t of parseLocksFromEvent(ev)) locked.add(t); - } - return locked; -} - function allocateTable({ area, people, lockedSet }) { const candidates = buildCandidates(area); let ok = candidates.filter((c) => people >= c.min && people <= c.max); ok = ok.filter((c) => c.locks.every((t) => !lockedSet.has(t))); - // Best fit: minimize wasted seats. Tie -> singles first. ok.sort((a, b) => { const wasteA = a.max - people; const wasteB = b.max - people; @@ -611,10 +613,9 @@ async function createBookingEvent({ const tz = GOOGLE_CALENDAR_TZ; const { startLocal, endLocal } = computeStartEndLocal(dateISO, time24, durationMinutes); - const privateKey = `callsid:${callSid || "no-callsid"}`; - // Idempotency: search existing event by privateKey in that date window + // Idempotency: search existing event by privateKey in day window const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -625,9 +626,7 @@ async function createBookingEvent({ }); const found = (existing.data.items || []).find((ev) => String(ev.description || "").includes(privateKey)); - if (found) { - return { created: false, eventId: found.id, htmlLink: found.htmlLink }; - } + if (found) return { created: false, eventId: found.id, htmlLink: found.htmlLink }; const prefix = autoConfirm ? "" : "DA CONFERMARE • "; const summary = `${prefix}TB • ${tableDisplayId} • ${name} • ${people} pax`; @@ -700,15 +699,10 @@ async function sendWhatsAppConfirmation({ `Tavolo: ${tableDisplayId} (${area === "inside" ? "interno" : "esterno"})`, ]; - if (bookingType === "drinks") { - lines.push(`Nota: a quest'orario la cucina potrebbe essere chiusa (solo drink e vino).`); - } + if (bookingType === "drinks") lines.push(`Nota: a quest'orario la cucina potrebbe essere chiusa (solo drink e vino).`); - if (specialRequestsRaw && normalizeText(specialRequestsRaw) !== "nessuna") { - lines.push(`Richieste: ${specialRequestsRaw}`); - } else { - lines.push(`Richieste: nessuna`); - } + if (specialRequestsRaw && normalizeText(specialRequestsRaw) !== "nessuna") lines.push(`Richieste: ${specialRequestsRaw}`); + else lines.push(`Richieste: nessuna`); if (preorderLabel) { let preorderLine = `Preordine: ${preorderLabel}`; @@ -720,9 +714,7 @@ async function sendWhatsAppConfirmation({ } } - if (outsideDisclaimer) { - lines.push(`Nota: ${outsideDisclaimer}`); - } + if (outsideDisclaimer) lines.push(`Nota: ${outsideDisclaimer}`); lines.push(`A presto da TuttiBrilli!`); @@ -803,6 +795,7 @@ app.post("/voice", async (req, res) => { } case 3: { + // FIX: no calendar calls here if (emptySpeech) { if (bumpRetries(session) > 2) { sayIt(vr, "Non ho capito l'orario. Ti passo un operatore."); @@ -828,26 +821,7 @@ app.post("/voice", async (req, res) => { session.time24 = time24; - const calendar = getCalendarClient(); - if (!calendar) throw new Error("Calendar client not configured"); - - // HARD BLOCK: "locale chiuso" on that date -> no bookings - const isClosed = await calendarHasMarkerOnDate(calendar, session.dateISO, "locale chiuso"); - if (isClosed) { - sayIt(vr, "Mi dispiace, per quella data il locale risulta chiuso e non posso fissare prenotazioni. Ti passo un operatore."); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Grazie."); - vr.hangup(); - sessions.delete(callSid); - break; - } - - // Promo eligibility: by day + not "no promo" marker on calendar - const dayOk = isPromoEligibleByDay(session.dateISO); - const hasNoPromo = await calendarHasMarkerOnDate(calendar, session.dateISO, "no promo"); - session.promoEligible = dayOk && !hasNoPromo; - - // Validate opening hours / booking type + // Opening hours logic only (no external calls) const { bookingType, autoConfirm, reason } = deriveBookingTypeAndConfirm(session.dateISO, session.time24); session.bookingType = bookingType; session.autoConfirm = autoConfirm; @@ -898,10 +872,7 @@ app.post("/voice", async (req, res) => { session.people = people; resetRetries(session); session.step = 5; - gatherSpeech( - vr, - "Ci sono allergie, intolleranze o richieste particolari? Puoi dire per esempio: nessuna, celiaco, senza lattosio, vegetariano, tavolo tranquillo." - ); + gatherSpeech(vr, "Ci sono allergie, intolleranze o richieste particolari? Puoi dire per esempio: nessuna, celiaco, senza lattosio, vegetariano, tavolo tranquillo."); break; } @@ -926,7 +897,6 @@ app.post("/voice", async (req, res) => { } case 6: { - // Preorder choice (single selection from fixed menu) if (emptySpeech) { if (bumpRetries(session) > 2) { session.preorderChoiceKey = null; @@ -936,7 +906,7 @@ app.post("/voice", async (req, res) => { gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); break; } - gatherSpeech(vr, "Non ho sentito. Vuoi preordinare? Di' cena, apericena, dopocena, piatto apericena, piatto apericena promo, oppure nessuno."); + gatherSpeech(vr, "Non ho sentito. Di' cena, apericena, dopocena, piatto apericena, piatto apericena promo, oppure nessuno."); break; } @@ -955,7 +925,6 @@ app.post("/voice", async (req, res) => { break; } - // "nessuno" if (!key) { session.preorderChoiceKey = null; session.preorderLabel = null; @@ -965,7 +934,6 @@ app.post("/voice", async (req, res) => { break; } - // validate constraints const opt = getPreorderOptionByKey(key); if (!opt) { session.preorderChoiceKey = null; @@ -976,7 +944,7 @@ app.post("/voice", async (req, res) => { break; } - // dopocena only after 22:30 + // dopocena constraint enforced here (no calendar needed) if (opt.constraints && opt.constraints.minTime) { if (!isTimeAtOrAfter(session.time24, opt.constraints.minTime)) { sayIt(vr, "Il dopocena è disponibile solo dopo le 22 e 30."); @@ -986,13 +954,7 @@ app.post("/voice", async (req, res) => { } } - // promo rule check: we allow selection, but if not eligible we warn & still record - if (opt.constraints && opt.constraints.promoOnly) { - if (!session.promoEligible) { - sayIt(vr, "Nota: oggi la promo potrebbe non essere valida, per giorno non promo, festivo o indicazione no promo. La segnalo comunque e verrà verificata."); - } - } - + // promo eligibility computed later (after phone, at final step) - here we just store choice session.preorderChoiceKey = opt.key; session.preorderLabel = opt.label; @@ -1003,7 +965,7 @@ app.post("/voice", async (req, res) => { } case 8: { - // Area selection with outside disclaimer + recommendation + // Area selection with outside disclaimer + recommendation + confirm if (emptySpeech) { if (bumpRetries(session) > 2) { session.area = "inside"; @@ -1081,7 +1043,7 @@ app.post("/voice", async (req, res) => { } case 10: { - // Phone / WhatsApp + // Phone / WhatsApp + FINAL STEP: Calendar checks + table lock + event creation + WhatsApp if (emptySpeech) { if (bumpRetries(session) > 2) { sayIt(vr, "Non ho capito il numero. Ti passo un operatore."); @@ -1109,12 +1071,45 @@ app.post("/voice", async (req, res) => { session.waTo = `whatsapp:${phone}`; resetRetries(session); - // ===== ALLOCATE TABLE + CALENDAR LOCK ===== + // Compute duration now (needed for Calendar event start/end) + const d = new Date(`${session.dateISO}T00:00:00`); + session.durationMinutes = getDurationMinutes(session.people, d); + + const outsideDisclaimer = + session.area === "outside" + ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." + : null; + + // Determine preorder price text + let preorderPriceText = null; + if (session.preorderChoiceKey) { + const opt = getPreorderOptionByKey(session.preorderChoiceKey); + if (opt && typeof opt.priceEUR === "number") preorderPriceText = `${opt.priceEUR} €`; + } + + // ==== FINAL Calendar operations (wrapped in local try/catch) ==== const calendar = getCalendarClient(); - if (!calendar) throw new Error("Calendar client not configured"); + if (!calendar) { + // Can't book without calendar. Offer operator. + sayIt(vr, "Mi dispiace, al momento non riesco a registrare la prenotazione. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } + + let isClosed = false; + let hasNoPromo = false; + + try { + isClosed = await calendarHasMarkerOnDate(calendar, session.dateISO, "locale chiuso"); + hasNoPromo = await calendarHasMarkerOnDate(calendar, session.dateISO, "no promo"); + } catch (err) { + console.error("[CALENDAR] marker checks error:", err); + // If marker checks fail, treat as not closed, promo unknown -> safest is mark promo as "da verificare" + isClosed = false; + hasNoPromo = true; // conservative: disable promo eligibility if can't verify + } - // Safety: re-check "locale chiuso" before committing - const isClosed = await calendarHasMarkerOnDate(calendar, session.dateISO, "locale chiuso"); if (isClosed) { sayIt(vr, "Mi dispiace, per quella data il locale risulta chiuso e non posso fissare prenotazioni. Ti passo un operatore."); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); @@ -1124,10 +1119,23 @@ app.post("/voice", async (req, res) => { break; } - const d = new Date(`${session.dateISO}T00:00:00`); - session.durationMinutes = getDurationMinutes(session.people, d); + // Promo eligibility computed here (final step) + const dayOk = isPromoEligibleByDay(session.dateISO); + session.promoEligible = dayOk && !hasNoPromo; + + // If user selected promo but not eligible, we keep it but it's "da verificare" (and we record that in Calendar/WA) + // Table lock + allocation + let lockedSet; + try { + lockedSet = await getLockedTables(calendar, session.dateISO); + } catch (err) { + console.error("[CALENDAR] getLockedTables error:", err); + sayIt(vr, "Mi dispiace, al momento non riesco a verificare la disponibilità dei tavoli. Ti passo un operatore."); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, "Puoi richiamare più tardi. Grazie."); + break; + } - const lockedSet = await getLockedTables(calendar, session.dateISO); const chosen = allocateTable({ area: session.area, people: session.people, @@ -1145,18 +1153,6 @@ app.post("/voice", async (req, res) => { session.tableLocks = chosen.locks; session.tableNotes = chosen.notes || null; - const outsideDisclaimer = - session.area === "outside" - ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." - : null; - - // Preorder price text (if any) - let preorderPriceText = null; - if (session.preorderChoiceKey) { - const opt = getPreorderOptionByKey(session.preorderChoiceKey); - if (opt && typeof opt.priceEUR === "number") preorderPriceText = `${opt.priceEUR} €`; - } - // Create Calendar Event (idempotent) let calResult; try { @@ -1189,7 +1185,7 @@ app.post("/voice", async (req, res) => { break; } - // WhatsApp ONLY if autoConfirm is true AND calendar ok + // WhatsApp ONLY if autoConfirm is true AND calendar succeeded if (session.autoConfirm) { try { await sendWhatsAppConfirmation({ @@ -1209,31 +1205,23 @@ app.post("/voice", async (req, res) => { }); } catch (e) { console.error("[WHATSAPP] send error:", e); - // Don't fail the call: calendar is created already. } } // Final voice confirmation if (session.autoConfirm) { let extra = ""; - if (session.area === "outside") { - extra = " Ti ricordo che all'esterno in caso di maltempo non è garantito il posto dentro."; - } + if (session.area === "outside") extra = " Ti ricordo che all'esterno in caso di maltempo non è garantito il posto dentro."; let preorderVoice = ""; - if (session.preorderLabel) { - preorderVoice = ` Ho segnato il preordine: ${session.preorderLabel}.`; - } + if (session.preorderLabel) preorderVoice = ` Ho segnato il preordine: ${session.preorderLabel}.`; sayIt( vr, `Perfetto ${session.name}. Ho registrato la prenotazione per ${session.people} persone il ${session.dateISO} alle ${session.time24}, tavolo ${session.tableDisplayId}.${preorderVoice}${extra} Ti ho inviato conferma su WhatsApp. A presto!` ); } else { - sayIt( - vr, - `Perfetto ${session.name}. Ho registrato la richiesta. Un operatore la confermerà appena possibile. Grazie e a presto!` - ); + sayIt(vr, `Perfetto ${session.name}. Ho registrato la richiesta. Un operatore la confermerà appena possibile. Grazie e a presto!`); } vr.hangup(); From a610d50cccee9fcc38e081d25f9fced004e4f1b0 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 02:22:08 +0100 Subject: [PATCH 031/143] Add Italian voice prompts for reservation system --- prompts.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 prompts.js diff --git a/prompts.js b/prompts.js new file mode 100644 index 0000000000..f414c95201 --- /dev/null +++ b/prompts.js @@ -0,0 +1,100 @@ +/** + * TuttiBrilli Enoteca — Voice Prompts (IT) + * SOLO TESTI. Nessuna logica, nessun flusso. + * Placeholders suggeriti: {{name}}, {{dateLabel}}, {{time}}, {{partySize}}, {{guess}} + */ + +const PROMPTS = { + step1_welcome_name: { + main: "Buonasera, TuttiBrilli Enoteca. Ti do una mano con la prenotazione. Partiamo dal nome: come ti chiami?", + short: "Buonasera, TuttiBrilli. Come ti chiami?", + error: "Perdonami, è andato un po’ via l’audio. Mi ripeti il nome?" + }, + + step2_confirm_name_ask_date: { + main: "Perfetto, piacere {{name}}. Per che giorno pensavi?", + short: "Perfetto {{name}}. Per che giorno?", + error: "Voglio essere sicuro di aver capito: il nome è {{guess}}?" + }, + + step3_confirm_date_ask_time: { + main: "Ok, {{dateLabel}}. A che ora ti va di venire?", + short: "Ok {{dateLabel}}. A che ora?", + error: "Scusami, non l’ho colto bene. Che giorno intendi?", + closedDay: { + main: "Ti fermo solo un attimo: quel giorno siamo chiusi. Se vuoi, guardiamo insieme un’altra data.", + short: "Quel giorno siamo chiusi. Vuoi provare un’altra data?" + }, + todayVariant: "Perfetto, per questa sera. A che ora?" + }, + + step4_confirm_time_ask_party_size: { + main: "Perfetto, alle {{time}}. In quanti sarete?", + short: "Ok {{time}}. In quanti?", + error: "Scusami, l’orario mi è sfuggito. Me lo ripeti?", + outsideHours: { + main: "Ti dico solo una cosa: a quell’ora il locale è ancora chiuso. Se vuoi, possiamo anticipare o spostarci un po’ più tardi.", + short: "A quell’ora siamo chiusi. Vuoi un altro orario?" + }, + kitchenClosed: { + main: "A quell’ora la cucina è chiusa, però siamo aperti per bere qualcosa. Va bene lo stesso o preferisci un altro orario?", + short: "A quell’ora la cucina è chiusa. Va bene lo stesso?" + }, + afterDinner: "Perfetto, quindi dopocena. Quante persone sarete?" + }, + + step5_party_size_ask_notes: { + main: "Perfetto, {{partySize}} persone. C’è qualche allergia, intolleranza o richiesta particolare?", + short: "Ok {{partySize}}. Allergie o richieste?", + error: "Scusami, non ho capito il numero. Me lo ripeti?", + largeGroupPositive: "Ok, {{partySize}} persone. Ci organizziamo senza problemi. C’è qualche allergia o richiesta particolare?", + checkingAvailability: "Un attimo solo che controllo la disponibilità per {{partySize}} persone. Ci metto pochissimo.", + noAvailability: "Ti dico la verità: per {{partySize}} persone a quell’orario siamo al completo. Se vuoi, proviamo un altro orario o un altro giorno." + }, + + step6_collect_notes: { + main: "Dimmi pure cosa devo segnalare.", + short: "Cosa devo segnare?", + error: "Scusami, non ho capito bene. Me lo ripeti con calma?", + reassure: "Anche solo per segnalarlo alla cucina.", + noneClose: "Perfetto, tutto ok." + }, + + step7_whatsapp_number: { + main: "Mi lasci un numero WhatsApp? Ti mando lì la conferma.", + short: "Un numero WhatsApp, per favore.", + reassure: "Solo per la conferma, niente altro.", + error: "Scusami, l’ho perso a metà. Me lo ripeti con calma?", + spokeTooFast: "Perfetto. Me lo ridici tutto di seguito, così non sbagliamo?", + afterCapture: "Ok, perfetto. Un attimo che ti riassumo tutto." + }, + + step8_summary_confirm: { + main: "Allora, ricapitoliamo un attimo. {{name}}, {{dateLabel}} alle {{time}}, per {{partySize}} persone. Va bene così?", + short: "Riepilogo veloce: {{dateLabel}}, {{time}}, {{partySize}} persone. Confermi?", + error: "Nessun problema, sistemiamo subito.", + confirmPrompt: "Se per te è tutto ok, confermo la prenotazione.", + confirmShort: "Confermo?", + hesitation: "Prenditi pure un secondo, non c’è fretta.", + outdoorWeather: "Ti segnalo solo una cosa: il tavolo è all’esterno e il meteo è un po’ incerto. Se cambia qualcosa, ci organizziamo senza problemi.", + kitchenNotActive: "A quell’orario la cucina non è attiva, però siamo aperti per bere qualcosa. Va bene lo stesso?", + promoNotValid: "Ti avviso solo che a quell’orario la promo non è attiva. Il resto resta invariato.", + tightAvailability: "È una disponibilità un po’ stretta, ma ci stiamo dentro. Se confermi, blocco subito." + }, + + step9_success: { + main: "Perfetto, la prenotazione è confermata. Tra poco ricevi un messaggio WhatsApp con tutti i dettagli. Ti aspettiamo da TuttiBrilli.", + short: "Fatto. Ti arriva subito la conferma su WhatsApp.", + reassure: "Sì, è tutto confermato. Tra pochissimo ti arriva il messaggio.", + goodbye: "A presto, buona serata." + }, + + step9_fallback_transfer_operator: { + main: "Un attimo solo, così ti seguiamo al meglio. Ti passo subito un collega.", + short: "Ti metto in contatto con un collega.", + gentle: "Capisco, meglio sentirci un attimo a voce. Resta in linea, ti passo un collega.", + value: "Così riusciamo a trovare la soluzione migliore per te." + } +}; + +module.exports = { PROMPTS }; From c3f572243ae42b2c64503f31af6eff373dcbf353 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 02:22:43 +0100 Subject: [PATCH 032/143] Update app.js --- app.js | 315 +++++++++++++++++++++++++-------------------------------- 1 file changed, 140 insertions(+), 175 deletions(-) diff --git a/app.js b/app.js index ce85d172bf..e56b3a6f6c 100644 --- a/app.js +++ b/app.js @@ -1,63 +1,12 @@ "use strict"; -/** - * TuttiBrilli Enoteca - Voice Booking Assistant (Single file, copy/paste) - * - * STACK: - * - Twilio Voice (Speech Gather) -> Node/Express - * - Google Calendar API (service account) for booking events - * - Twilio WhatsApp confirmation ONLY after calendar success - * - * IMPORTANT FIX (vs previous version): - * ✅ NO Google Calendar calls during early steps (name/date/time/etc.) - * Calendar is called ONLY at the final step (after phone) to: - * - block "locale chiuso" - * - detect "no promo" - * - lock tables (avoid double-booking) - * - create the booking event - * - send WhatsApp (only if Calendar succeeded and autoConfirm=true) - * - * FEATURES: - * - Opening hours rules (restaurant/drinks/operator on Mondays) - * - Table durations (+30min Wed/Fri music nights for <=8 pax) - * - Table map (inside/outside) + combinations/unions - * - Always ask inside/outside; if outside => warn + recommend inside + confirm - * - Menu preorder (fixed options): - * - cena - * - apericena - * - dopocena (only after 22:30) - * - Piatto Apericena (25€) - * - Piatto Apericena in promo (eligibility rules + "no promo" marker) - * - Promo rules: - * - Eligible Tue-Sun excluding Fri - * - Exclude holidays listed in ENV HOLIDAYS_YYYY_MM_DD - * - Exclude if Calendar has marker "no promo" on that date - * - Block all bookings if Calendar has marker "locale chiuso" on that date - * - Store allergies/intolerances/special requests in Calendar + WhatsApp - * - * ENV REQUIRED: - * PORT - * BASE_URL (e.g. https://your-service.onrender.com) <-- required for Twilio gather action URL - * - * TWILIO_ACCOUNT_SID - * TWILIO_AUTH_TOKEN - * TWILIO_WHATSAPP_FROM (e.g. whatsapp:+14155238886 or approved sender) - * - * GOOGLE_CALENDAR_ID - * GOOGLE_CALENDAR_TZ (default Europe/Rome) - * GOOGLE_SERVICE_ACCOUNT_JSON_B64 (recommended) OR GOOGLE_SERVICE_ACCOUNT_JSON (raw JSON string) - * - * OPTIONAL: - * ENABLE_FORWARDING=true/false - * HUMAN_FORWARD_TO=+39... - * - * HOLIDAYS_YYYY_MM_DD="2025-01-01,2025-04-20,2025-12-25" // promo exclusion on holidays - */ - const express = require("express"); const twilio = require("twilio"); const { google } = require("googleapis"); +// ✅ PROMPTS layer (SOLO TESTI) +const { PROMPTS } = require("./prompts"); + const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); @@ -89,16 +38,41 @@ const HOLIDAYS_SET = new Set( const twilioClient = TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) : null; +// ======================= PROMPTS HELPERS ======================= +// t("step.key.variant", { vars }) -> string with {{placeholders}} replaced +function renderTemplate(str, vars = {}) { + const s = String(str || ""); + return s.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { + const v = vars[key]; + return v === undefined || v === null ? "" : String(v); + }); +} + +function pickPrompt(path, fallback = "") { + // path like: "step1_welcome_name.main" + const parts = String(path || "").split("."); + let node = PROMPTS; + for (const p of parts) { + if (!node || typeof node !== "object" || !(p in node)) return fallback; + node = node[p]; + } + if (typeof node === "string") return node; + return fallback; +} + +function t(path, vars = {}, fallback = "") { + return renderTemplate(pickPrompt(path, fallback), vars); +} + // ======================= CONFIG: OPENING HOURS ======================= -// 0=Sunday ... 6=Saturday const OPENING = { - closedDay: 1, // Monday + closedDay: 1, restaurant: { - default: { start: "18:30", end: "22:30" }, // Tue-Thu, Sun - friSat: { start: "18:30", end: "23:00" }, // Fri-Sat + default: { start: "18:30", end: "22:30" }, + friSat: { start: "18:30", end: "23:00" }, }, - drinksOnly: { start: "18:30", end: "24:00" }, // everyday - musicNights: { days: [3, 5], from: "20:00" }, // Wed(3) & Fri(5) + drinksOnly: { start: "18:30", end: "24:00" }, + musicNights: { days: [3, 5], from: "20:00" }, }; // ======================= CONFIG: PREORDER MENU ======================= @@ -117,7 +91,6 @@ const PREORDER_OPTIONS = [ // ======================= CONFIG: TABLES ======================= const TABLES = [ - // INSIDE { id: "T1", area: "inside", min: 2, max: 4, notes: "più riservato" }, { id: "T2", area: "inside", min: 2, max: 4, notes: "più riservato" }, { id: "T3", area: "inside", min: 2, max: 4, notes: "più riservato" }, @@ -136,7 +109,6 @@ const TABLES = [ { id: "T16", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, { id: "T17", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, - // OUTSIDE { id: "T1F", area: "outside", min: 2, max: 2, notes: "botte con sgabelli" }, { id: "T2F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, { id: "T3F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, @@ -147,7 +119,6 @@ const TABLES = [ ]; const TABLE_COMBINATIONS = [ - // INSIDE unions { displayId: "T1", area: "inside", replaces: ["T1", "T2"], min: 6, max: 6, notes: "unione T1+T2" }, { displayId: "T3", area: "inside", replaces: ["T3", "T4"], min: 6, max: 6, notes: "unione T3+T4" }, { displayId: "T14", area: "inside", replaces: ["T14", "T15"], min: 8, max: 18, notes: "unione T14+T15" }, @@ -156,11 +127,10 @@ const TABLE_COMBINATIONS = [ { displayId: "T11", area: "inside", replaces: ["T11", "T12", "T13"], min: 8, max: 10, notes: "unione T11+T12+T13" }, { displayId: "T16", area: "inside", replaces: ["T16", "T17"], min: 8, max: 10, notes: "unione T16+T17" }, - // OUTSIDE union { displayId: "T7F", area: "outside", replaces: ["T7F", "T8F"], min: 6, max: 8, notes: "unione T7F+T8F" }, ]; -// ======================= SESSIONS (in-memory) ======================= +// ======================= SESSIONS ======================= const sessions = new Map(); function getSession(callSid) { @@ -180,24 +150,20 @@ function getSession(callSid) { preorderChoiceKey: null, preorderLabel: null, - // area - area: null, // inside/outside + area: null, pendingOutsideConfirm: false, phone: null, waTo: null, - // table allocation result tableDisplayId: null, tableLocks: [], tableNotes: null, - // derived durationMinutes: null, - bookingType: "restaurant", // restaurant/drinks/operator + bookingType: "restaurant", autoConfirm: true, - // computed only at final step (after Calendar checks) promoEligible: null, }); } @@ -212,7 +178,7 @@ function resetRetries(session) { session.retries = 0; } -// ======================= TEXT / PARSERS ======================= +// ======================= PARSERS ======================= function normalizeText(s) { return String(s || "").trim().toLowerCase().replace(/\s+/g, " "); } @@ -310,14 +276,6 @@ function getPreorderOptionByKey(key) { return PREORDER_OPTIONS.find((o) => o.key === key) || null; } -function parseYesNoIT(speech) { - const t = normalizeText(speech); - if (!t) return null; - if (t.includes("si") || t.includes("sì") || t.includes("certo") || t.includes("ok") || t.includes("va bene")) return true; - if (t === "no" || t.includes("no") || t.includes("non") || t.includes("niente") || t.includes("nessun")) return false; - return null; -} - function isValidPhoneE164(s) { return /^\+\d{8,15}$/.test(String(s || "").trim()); } @@ -345,15 +303,15 @@ function isTimeAtOrAfter(time24, minTime24) { } function getRestaurantWindowForDay(day) { - if (day === 5 || day === 6) return OPENING.restaurant.friSat; // Fri, Sat + if (day === 5 || day === 6) return OPENING.restaurant.friSat; return OPENING.restaurant.default; } function isWithinWindow(time24, startHM, endHM) { - const t = hmToMinutes(time24); + const tmin = hmToMinutes(time24); const start = hmToMinutes(startHM); const end = hmToMinutes(endHM); - return t >= start && t <= end; + return tmin >= start && tmin <= end; } function deriveBookingTypeAndConfirm(dateISO, time24) { @@ -408,7 +366,7 @@ function computeStartEndLocal(dateISO, time24, durationMinutes) { return { startLocal, endLocal }; } -// ======================= TWILIO TWIML HELPERS ======================= +// ======================= TWILIO HELPERS ======================= function buildTwiml() { return new twilio.twiml.VoiceResponse(); } @@ -435,7 +393,8 @@ function canForwardToHuman() { function forwardToHumanTwiml() { const vr = buildTwiml(); - sayIt(vr, "Ti passo subito un operatore. Resta in linea."); + // ✅ PROMPTS: operator transfer + sayIt(vr, t("step9_fallback_transfer_operator.main")); vr.dial({}, HUMAN_FORWARD_TO); return vr.toString(); } @@ -481,7 +440,7 @@ function getCalendarClient() { return google.calendar({ version: "v3", auth }); } -// ======================= CALENDAR HELPERS (markers / locks / idempotency) ======================= +// ======================= CALENDAR HELPERS ======================= function containsMarker(ev, markerLower) { const s = `${String(ev.summary || "")}\n${String(ev.description || "")}`.toLowerCase(); return s.includes(markerLower); @@ -498,14 +457,12 @@ async function calendarHasMarkerOnDate(calendar, dateISO, markerLower) { maxResults: 250, orderBy: "startTime", }); - return (resp.data.items || []).some((ev) => containsMarker(ev, markerLower)); } function isPromoEligibleByDay(dateISO) { const d = new Date(`${dateISO}T00:00:00`); const day = d.getDay(); - // Tue-Sun excluding Fri (Tue=2, Wed=3, Thu=4, Sat=6, Sun=0) const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; if (!allowedDay) return false; if (HOLIDAYS_SET.has(dateISO)) return false; @@ -534,7 +491,7 @@ async function getLockedTables(calendar, dateISO) { const locked = new Set(); for (const ev of resp.data.items || []) { - for (const t of parseLocksFromEvent(ev)) locked.add(t); + for (const tId of parseLocksFromEvent(ev)) locked.add(tId); } return locked; } @@ -542,14 +499,14 @@ async function getLockedTables(calendar, dateISO) { // ======================= TABLE ALLOCATION ======================= function buildCandidates(area) { const singles = TABLES - .filter((t) => t.area === area) - .map((t) => ({ - displayId: t.id, - locks: [t.id], - min: t.min, - max: t.max, - area: t.area, - notes: t.notes || "", + .filter((tt) => tt.area === area) + .map((tt) => ({ + displayId: tt.id, + locks: [tt.id], + min: tt.min, + max: tt.max, + area: tt.area, + notes: tt.notes || "", kind: "single", })); @@ -572,7 +529,7 @@ function allocateTable({ area, people, lockedSet }) { const candidates = buildCandidates(area); let ok = candidates.filter((c) => people >= c.min && people <= c.max); - ok = ok.filter((c) => c.locks.every((t) => !lockedSet.has(t))); + ok = ok.filter((c) => c.locks.every((tId) => !lockedSet.has(tId))); ok.sort((a, b) => { const wasteA = a.max - people; @@ -586,7 +543,7 @@ function allocateTable({ area, people, lockedSet }) { return ok[0]; } -// ======================= GOOGLE CALENDAR EVENT CREATION (IDEMPOTENT) ======================= +// ======================= EVENT CREATION (IDEMPOTENT) ======================= async function createBookingEvent({ callSid, name, @@ -615,7 +572,6 @@ async function createBookingEvent({ const { startLocal, endLocal } = computeStartEndLocal(dateISO, time24, durationMinutes); const privateKey = `callsid:${callSid || "no-callsid"}`; - // Idempotency: search existing event by privateKey in day window const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -745,158 +701,183 @@ app.post("/voice", async (req, res) => { return res.type("text/xml").send(vr.toString()); } - const speechNorm = normalizeText(speech); - const emptySpeech = !speechNorm; + const emptySpeech = !normalizeText(speech); switch (session.step) { case 1: { if (emptySpeech) { resetRetries(session); - gatherSpeech(vr, "Ciao! Benvenuto da TuttiBrilli. Dimmi il tuo nome per la prenotazione."); + // ✅ PROMPTS + gatherSpeech(vr, t("step1_welcome_name.main")); break; } session.name = speech.trim().slice(0, 60); resetRetries(session); session.step = 2; - gatherSpeech(vr, `Perfetto ${session.name}. Per quale data vuoi prenotare? Puoi dire per esempio domani o 30 dicembre.`); + + // ✅ PROMPTS + gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); break; } case 2: { if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non ho capito la data. Ti passo un operatore."); + // ✅ PROMPTS + operator transfer (same logic) + sayIt(vr, t("step3_confirm_date_ask_time.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - gatherSpeech(vr, "Non ho sentito la data. Dimmi la data della prenotazione."); + // ✅ PROMPTS (error name prompt used as generic “repeat”) + gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } const dateISO = parseDateIT(speech); if (!dateISO) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non riesco a capire la data. Ti passo un operatore."); + sayIt(vr, t("step3_confirm_date_ask_time.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - gatherSpeech(vr, "Scusa, non ho capito. Dimmi la data, ad esempio: 30 12 2025, oppure domani."); + // ✅ PROMPTS + gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } session.dateISO = dateISO; resetRetries(session); session.step = 3; - gatherSpeech(vr, "A che ora? Ad esempio: 20 e 30."); + + // ✅ PROMPTS + gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO })); break; } case 3: { - // FIX: no calendar calls here + // FIX: no calendar calls here (logic unchanged) if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non ho capito l'orario. Ti passo un operatore."); + // ✅ PROMPTS (time error) + sayIt(vr, t("step4_confirm_time_ask_party_size.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - gatherSpeech(vr, "Non ho sentito l'orario. Dimmi a che ora vuoi prenotare."); + // ✅ PROMPTS + gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); break; } const time24 = parseTimeIT(speech); if (!time24) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non riesco a capire l'orario. Ti passo un operatore."); + sayIt(vr, t("step4_confirm_time_ask_party_size.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - gatherSpeech(vr, "Scusa, non ho capito. Dimmi l'orario, ad esempio 20 e 30."); + // ✅ PROMPTS + gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); break; } session.time24 = time24; - // Opening hours logic only (no external calls) const { bookingType, autoConfirm, reason } = deriveBookingTypeAndConfirm(session.dateISO, session.time24); session.bookingType = bookingType; session.autoConfirm = autoConfirm; if (bookingType === "closed") { - sayIt(vr, `A quell'orario siamo chiusi. ${reason ? reason : ""} Ti passo un operatore.`); + // ✅ PROMPTS + sayIt(vr, t("step4_confirm_time_ask_party_size.outsideHours.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare durante l'orario di apertura. Grazie."); break; } if (bookingType === "operator") { + // (nessun prompt specifico in file per questa frase: lasciato invariato) sayIt(vr, "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo."); } else if (bookingType === "drinks") { - sayIt(vr, "Nota: a quest'orario la cucina potrebbe essere chiusa. Possiamo fare solo drink e vino."); + // ✅ PROMPTS (kitchen closed) + sayIt(vr, t("step4_confirm_time_ask_party_size.kitchenClosed.main")); } resetRetries(session); session.step = 4; - gatherSpeech(vr, "Per quante persone?"); + + // ✅ PROMPTS + gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 })); break; } case 4: { if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non ho capito il numero di persone. Ti passo un operatore."); + // ✅ PROMPTS + sayIt(vr, t("step5_party_size_ask_notes.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - gatherSpeech(vr, "Non ho sentito. Per quante persone?"); + // ✅ PROMPTS + gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; } const people = parsePeopleIT(speech); if (!people || people < 1 || people > 18) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non riesco a gestire questa prenotazione automaticamente. Ti passo un operatore."); + sayIt(vr, t("step5_party_size_ask_notes.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Grazie."); break; } - gatherSpeech(vr, "Scusa, quante persone? Dimmi un numero tra 1 e 18."); + // ✅ PROMPTS + gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; } session.people = people; resetRetries(session); session.step = 5; - gatherSpeech(vr, "Ci sono allergie, intolleranze o richieste particolari? Puoi dire per esempio: nessuna, celiaco, senza lattosio, vegetariano, tavolo tranquillo."); + + // ✅ PROMPTS + gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); break; } case 5: { + // Notes collection (logic unchanged) if (emptySpeech) { if (bumpRetries(session) > 2) { session.specialRequestsRaw = "nessuna"; resetRetries(session); session.step = 6; + // (preorder prompt not in PROMPTS: left unchanged) gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); break; } - gatherSpeech(vr, "Non ho sentito. Ci sono allergie o richieste particolari? Se non ce ne sono, di' nessuna."); + // ✅ PROMPTS + gatherSpeech(vr, t("step6_collect_notes.error")); break; } session.specialRequestsRaw = speech.trim().slice(0, 200); resetRetries(session); session.step = 6; + + // (preorder prompt not in PROMPTS: left unchanged) gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); break; } case 6: { + // Preorder choice (logic unchanged; no matching prompts in provided file) if (emptySpeech) { if (bumpRetries(session) > 2) { session.preorderChoiceKey = null; @@ -944,7 +925,6 @@ app.post("/voice", async (req, res) => { break; } - // dopocena constraint enforced here (no calendar needed) if (opt.constraints && opt.constraints.minTime) { if (!isTimeAtOrAfter(session.time24, opt.constraints.minTime)) { sayIt(vr, "Il dopocena è disponibile solo dopo le 22 e 30."); @@ -954,7 +934,6 @@ app.post("/voice", async (req, res) => { } } - // promo eligibility computed later (after phone, at final step) - here we just store choice session.preorderChoiceKey = opt.key; session.preorderLabel = opt.label; @@ -965,13 +944,14 @@ app.post("/voice", async (req, res) => { } case 8: { - // Area selection with outside disclaimer + recommendation + confirm + // Area selection (logic unchanged; no matching prompts in provided file) if (emptySpeech) { if (bumpRetries(session) > 2) { session.area = "inside"; resetRetries(session); session.step = 10; - gatherSpeech(vr, "Perfetto. Dimmi il tuo numero di telefono, anche per WhatsApp."); + // ✅ PROMPTS for WhatsApp request + gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } gatherSpeech(vr, "Non ho capito. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); @@ -980,22 +960,24 @@ app.post("/voice", async (req, res) => { if (session.pendingOutsideConfirm) { const area = parseAreaIT(speech); - const t = normalizeText(speech); + const tt = normalizeText(speech); if (area === "inside") { session.area = "inside"; session.pendingOutsideConfirm = false; resetRetries(session); session.step = 10; - gatherSpeech(vr, "Perfetto, interno. Dimmi il tuo numero di telefono, anche per WhatsApp."); + // ✅ PROMPTS + gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } - if (area === "outside" || t.includes("confermo") || t.includes("va bene esterno")) { + if (area === "outside" || tt.includes("confermo") || tt.includes("va bene esterno")) { session.area = "outside"; session.pendingOutsideConfirm = false; resetRetries(session); session.step = 10; - gatherSpeech(vr, "Perfetto, esterno. Dimmi il tuo numero di telefono, anche per WhatsApp."); + // ✅ PROMPTS + gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } @@ -1004,7 +986,8 @@ app.post("/voice", async (req, res) => { session.pendingOutsideConfirm = false; resetRetries(session); session.step = 10; - gatherSpeech(vr, "Ok, ti assegno un tavolo interno. Dimmi il tuo numero di telefono."); + // ✅ PROMPTS + gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } @@ -1018,7 +1001,8 @@ app.post("/voice", async (req, res) => { session.area = "inside"; resetRetries(session); session.step = 10; - gatherSpeech(vr, "Ok, ti assegno un tavolo interno. Dimmi il tuo numero di telefono."); + // ✅ PROMPTS + gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } gatherSpeech(vr, "Scusa, non ho capito. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); @@ -1038,32 +1022,36 @@ app.post("/voice", async (req, res) => { session.area = "inside"; resetRetries(session); session.step = 10; - gatherSpeech(vr, "Perfetto, interno. Dimmi il tuo numero di telefono, anche per WhatsApp."); + // ✅ PROMPTS + gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } case 10: { - // Phone / WhatsApp + FINAL STEP: Calendar checks + table lock + event creation + WhatsApp + // Phone / WhatsApp + FINAL step calendar if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non ho capito il numero. Ti passo un operatore."); + // ✅ PROMPTS + operator transfer (logic unchanged) + sayIt(vr, t("step7_whatsapp_number.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - gatherSpeech(vr, "Non ho sentito il numero. Dimmi il tuo numero di telefono, per WhatsApp."); + // ✅ PROMPTS + gatherSpeech(vr, t("step7_whatsapp_number.error")); break; } const phone = extractPhoneFromSpeech(speech); if (!phone || !isValidPhoneE164(phone)) { if (bumpRetries(session) > 2) { - sayIt(vr, "Non riesco a capire il numero. Ti passo un operatore."); + sayIt(vr, t("step7_whatsapp_number.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Grazie."); break; } - gatherSpeech(vr, "Scusa, non ho capito. Dimmi il numero in questo formato: più trentanove, e poi il numero."); + // ✅ PROMPTS (spokeTooFast as “repeat”) + gatherSpeech(vr, t("step7_whatsapp_number.spokeTooFast")); break; } @@ -1071,7 +1059,6 @@ app.post("/voice", async (req, res) => { session.waTo = `whatsapp:${phone}`; resetRetries(session); - // Compute duration now (needed for Calendar event start/end) const d = new Date(`${session.dateISO}T00:00:00`); session.durationMinutes = getDurationMinutes(session.people, d); @@ -1080,18 +1067,15 @@ app.post("/voice", async (req, res) => { ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." : null; - // Determine preorder price text let preorderPriceText = null; if (session.preorderChoiceKey) { const opt = getPreorderOptionByKey(session.preorderChoiceKey); if (opt && typeof opt.priceEUR === "number") preorderPriceText = `${opt.priceEUR} €`; } - // ==== FINAL Calendar operations (wrapped in local try/catch) ==== const calendar = getCalendarClient(); if (!calendar) { - // Can't book without calendar. Offer operator. - sayIt(vr, "Mi dispiace, al momento non riesco a registrare la prenotazione. Ti passo un operatore."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; @@ -1105,13 +1089,12 @@ app.post("/voice", async (req, res) => { hasNoPromo = await calendarHasMarkerOnDate(calendar, session.dateISO, "no promo"); } catch (err) { console.error("[CALENDAR] marker checks error:", err); - // If marker checks fail, treat as not closed, promo unknown -> safest is mark promo as "da verificare" isClosed = false; - hasNoPromo = true; // conservative: disable promo eligibility if can't verify + hasNoPromo = true; } if (isClosed) { - sayIt(vr, "Mi dispiace, per quella data il locale risulta chiuso e non posso fissare prenotazioni. Ti passo un operatore."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Grazie."); vr.hangup(); @@ -1119,18 +1102,15 @@ app.post("/voice", async (req, res) => { break; } - // Promo eligibility computed here (final step) const dayOk = isPromoEligibleByDay(session.dateISO); session.promoEligible = dayOk && !hasNoPromo; - // If user selected promo but not eligible, we keep it but it's "da verificare" (and we record that in Calendar/WA) - // Table lock + allocation let lockedSet; try { lockedSet = await getLockedTables(calendar, session.dateISO); } catch (err) { console.error("[CALENDAR] getLockedTables error:", err); - sayIt(vr, "Mi dispiace, al momento non riesco a verificare la disponibilità dei tavoli. Ti passo un operatore."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; @@ -1143,7 +1123,7 @@ app.post("/voice", async (req, res) => { }); if (!chosen) { - sayIt(vr, "Mi dispiace, per quell'orario non ho tavoli disponibili nella sala scelta. Ti passo un operatore per trovare una soluzione."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi provare un altro orario. Grazie."); break; @@ -1153,10 +1133,8 @@ app.post("/voice", async (req, res) => { session.tableLocks = chosen.locks; session.tableNotes = chosen.notes || null; - // Create Calendar Event (idempotent) - let calResult; try { - calResult = await createBookingEvent({ + await createBookingEvent({ callSid, name: session.name, dateISO: session.dateISO, @@ -1179,13 +1157,12 @@ app.post("/voice", async (req, res) => { }); } catch (e) { console.error("[CALENDAR] create error:", e); - sayIt(vr, "Mi dispiace, c'è stato un problema nel registrare la prenotazione. Ti passo un operatore."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - // WhatsApp ONLY if autoConfirm is true AND calendar succeeded if (session.autoConfirm) { try { await sendWhatsAppConfirmation({ @@ -1208,21 +1185,9 @@ app.post("/voice", async (req, res) => { } } - // Final voice confirmation - if (session.autoConfirm) { - let extra = ""; - if (session.area === "outside") extra = " Ti ricordo che all'esterno in caso di maltempo non è garantito il posto dentro."; - - let preorderVoice = ""; - if (session.preorderLabel) preorderVoice = ` Ho segnato il preordine: ${session.preorderLabel}.`; - - sayIt( - vr, - `Perfetto ${session.name}. Ho registrato la prenotazione per ${session.people} persone il ${session.dateISO} alle ${session.time24}, tavolo ${session.tableDisplayId}.${preorderVoice}${extra} Ti ho inviato conferma su WhatsApp. A presto!` - ); - } else { - sayIt(vr, `Perfetto ${session.name}. Ho registrato la richiesta. Un operatore la confermerà appena possibile. Grazie e a presto!`); - } + // ✅ PROMPTS: success + goodbye (no business logic change) + sayIt(vr, t("step9_success.main")); + sayIt(vr, t("step9_success.goodbye")); vr.hangup(); sessions.delete(callSid); @@ -1230,7 +1195,7 @@ app.post("/voice", async (req, res) => { } default: { - sayIt(vr, "Grazie. A presto!"); + sayIt(vr, t("step9_success.goodbye")); vr.hangup(); sessions.delete(callSid); break; @@ -1242,7 +1207,7 @@ app.post("/voice", async (req, res) => { console.error("[VOICE] Error:", err); sayIt(vr, "Mi dispiace, c'è stato un errore tecnico. Riprova tra poco."); if (canForwardToHuman()) { - sayIt(vr, "Ti passo un operatore."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); vr.dial({}, HUMAN_FORWARD_TO); } else { vr.hangup(); From 84f7e2f7c6e236bfc50fe7e955d95492ae4b71f1 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 02:31:39 +0100 Subject: [PATCH 033/143] Update prompts.js From 7f20df773a463c2e54174a4531e4df75f6291131 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 02:36:55 +0100 Subject: [PATCH 034/143] Update app.js --- app.js | 223 +++++++++++++++++++++++++++++++++------------------------ 1 file changed, 128 insertions(+), 95 deletions(-) diff --git a/app.js b/app.js index e56b3a6f6c..eec5ba9c2a 100644 --- a/app.js +++ b/app.js @@ -3,8 +3,6 @@ const express = require("express"); const twilio = require("twilio"); const { google } = require("googleapis"); - -// ✅ PROMPTS layer (SOLO TESTI) const { PROMPTS } = require("./prompts"); const app = express(); @@ -29,17 +27,13 @@ const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; const HOLIDAYS_YYYY_MM_DD = process.env.HOLIDAYS_YYYY_MM_DD || ""; const HOLIDAYS_SET = new Set( - HOLIDAYS_YYYY_MM_DD - .split(",") - .map((s) => s.trim()) - .filter(Boolean) + HOLIDAYS_YYYY_MM_DD.split(",").map((s) => s.trim()).filter(Boolean) ); const twilioClient = TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) : null; // ======================= PROMPTS HELPERS ======================= -// t("step.key.variant", { vars }) -> string with {{placeholders}} replaced function renderTemplate(str, vars = {}) { const s = String(str || ""); return s.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { @@ -49,15 +43,13 @@ function renderTemplate(str, vars = {}) { } function pickPrompt(path, fallback = "") { - // path like: "step1_welcome_name.main" const parts = String(path || "").split("."); let node = PROMPTS; for (const p of parts) { if (!node || typeof node !== "object" || !(p in node)) return fallback; node = node[p]; } - if (typeof node === "string") return node; - return fallback; + return typeof node === "string" ? node : fallback; } function t(path, vars = {}, fallback = "") { @@ -66,13 +58,13 @@ function t(path, vars = {}, fallback = "") { // ======================= CONFIG: OPENING HOURS ======================= const OPENING = { - closedDay: 1, + closedDay: 1, // Monday restaurant: { - default: { start: "18:30", end: "22:30" }, - friSat: { start: "18:30", end: "23:00" }, + default: { start: "18:30", end: "22:30" }, // Tue-Thu, Sun + friSat: { start: "18:30", end: "23:00" }, // Fri-Sat }, - drinksOnly: { start: "18:30", end: "24:00" }, - musicNights: { days: [3, 5], from: "20:00" }, + drinksOnly: { start: "18:30", end: "24:00" }, // everyday + musicNights: { days: [3, 5], from: "20:00" }, // Wed & Fri }; // ======================= CONFIG: PREORDER MENU ======================= @@ -81,16 +73,12 @@ const PREORDER_OPTIONS = [ { key: "apericena", label: "Apericena", priceEUR: null, constraints: {} }, { key: "dopocena", label: "Dopocena (dopo le 22:30)", priceEUR: null, constraints: { minTime: "22:30" } }, { key: "piatto_apericena", label: "Piatto Apericena", priceEUR: 25, constraints: {} }, - { - key: "piatto_apericena_promo", - label: "Piatto Apericena in promo (previa registrazione)", - priceEUR: null, - constraints: { promoOnly: true }, - }, + { key: "piatto_apericena_promo", label: "Piatto Apericena in promo (previa registrazione)", priceEUR: null, constraints: { promoOnly: true } }, ]; // ======================= CONFIG: TABLES ======================= const TABLES = [ + // INSIDE { id: "T1", area: "inside", min: 2, max: 4, notes: "più riservato" }, { id: "T2", area: "inside", min: 2, max: 4, notes: "più riservato" }, { id: "T3", area: "inside", min: 2, max: 4, notes: "più riservato" }, @@ -109,6 +97,7 @@ const TABLES = [ { id: "T16", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, { id: "T17", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, + // OUTSIDE { id: "T1F", area: "outside", min: 2, max: 2, notes: "botte con sgabelli" }, { id: "T2F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, { id: "T3F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, @@ -119,6 +108,7 @@ const TABLES = [ ]; const TABLE_COMBINATIONS = [ + // INSIDE unions { displayId: "T1", area: "inside", replaces: ["T1", "T2"], min: 6, max: 6, notes: "unione T1+T2" }, { displayId: "T3", area: "inside", replaces: ["T3", "T4"], min: 6, max: 6, notes: "unione T3+T4" }, { displayId: "T14", area: "inside", replaces: ["T14", "T15"], min: 8, max: 18, notes: "unione T14+T15" }, @@ -127,6 +117,7 @@ const TABLE_COMBINATIONS = [ { displayId: "T11", area: "inside", replaces: ["T11", "T12", "T13"], min: 8, max: 10, notes: "unione T11+T12+T13" }, { displayId: "T16", area: "inside", replaces: ["T16", "T17"], min: 8, max: 10, notes: "unione T16+T17" }, + // OUTSIDE union { displayId: "T7F", area: "outside", replaces: ["T7F", "T8F"], min: 6, max: 8, notes: "unione T7F+T8F" }, ]; @@ -194,8 +185,21 @@ function nowLocal() { return new Date(); } +/** + * ✅ DATE PARSER ENHANCED: + * - oggi, domani + * - 30/12, 30-12, 30 12 (+ optional year) + * - 2025-12-30 + * - 30 dicembre / martedì 30 dicembre + * - questo venerdì / prossimo venerdì / venerdì prossimo / venerdì + */ function parseDateIT(speech) { - const t = normalizeText(speech); + const t0 = normalizeText(speech); + const t = t0 + .replace(/[,\.]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const today = nowLocal(); if (t.includes("oggi")) return toISODate(today); @@ -204,9 +208,11 @@ function parseDateIT(speech) { return toISODate(d); } + // 1) ISO yyyy-mm-dd const iso = t.match(/(\d{4})-(\d{2})-(\d{2})/); if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; + // 2) dd/mm(/yyyy) or dd-mm(-yyyy) const dmY = t.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); if (dmY) { let dd = Number(dmY[1]); @@ -219,6 +225,83 @@ function parseDateIT(speech) { } } + // 3) dd mm (yyyy) -> es: "30 12" o "30 12 2025" + const dmSpace = t.match(/\b(\d{1,2})\s+(\d{1,2})(?:\s+(\d{2,4}))?\b/); + if (dmSpace) { + let dd = Number(dmSpace[1]); + let mm = Number(dmSpace[2]); + let yy = dmSpace[3] ? Number(dmSpace[3]) : today.getFullYear(); + if (yy < 100) yy += 2000; + if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { + const d = new Date(yy, mm - 1, dd); + return toISODate(d); + } + } + + // 4) weekday phrases: "questo venerdì", "prossimo venerdì", "venerdì prossimo", "venerdì" + const weekdayMap = { + domenica: 0, + lunedi: 1, "lunedì": 1, + martedi: 2, "martedì": 2, + mercoledi: 3, "mercoledì": 3, + giovedi: 4, "giovedì": 4, + venerdi: 5, "venerdì": 5, + sabato: 6, + }; + + const hasQuesto = /\b(questo|questa|sto|sta)\b/.test(t); + const hasProssimo = /\b(prossimo|prossima)\b/.test(t); + const hasGiornoProssimoPost = /\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\s+prossim[oa]\b/.test(t); + + const weekdayMatch = t.match(/\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\b/); + if (weekdayMatch) { + const wdToken = weekdayMatch[1]; + const target = weekdayMap[wdToken]; + + const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const current = d.getDay(); + + let diff = (target - current + 7) % 7; + const wantsNext = hasProssimo || hasGiornoProssimoPost; + + if (wantsNext) { + if (diff === 0) diff = 7; + else diff += 7; + } else if (hasQuesto) { + // allow today if same day; otherwise upcoming (diff ok) + } else { + // bare weekday -> upcoming occurrence (today if same day) + } + + d.setDate(d.getDate() + diff); + return toISODate(d); + } + + // 5) "30 dicembre" / "martedì 30 dicembre" + const months = { + gennaio: 1, febbraio: 2, marzo: 3, aprile: 4, maggio: 5, giugno: 6, + luglio: 7, agosto: 8, settembre: 9, ottobre: 10, novembre: 11, dicembre: 12, + }; + + const cleaned = t + .replace(/\b(lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato|domenica)\b/g, " ") + .replace(/\b(questo|questa|prossimo|prossima|il|lo|la|per|di)\b/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const m = cleaned.match(/\b(\d{1,2})\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); + if (m) { + const dd = Number(m[1]); + const mm = months[m[2]]; + let yy = m[3] ? Number(m[3]) : today.getFullYear(); + if (yy < 100) yy += 2000; + + if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { + const d = new Date(yy, mm - 1, dd); + return toISODate(d); + } + } + return null; } @@ -303,7 +386,7 @@ function isTimeAtOrAfter(time24, minTime24) { } function getRestaurantWindowForDay(day) { - if (day === 5 || day === 6) return OPENING.restaurant.friSat; + if (day === 5 || day === 6) return OPENING.restaurant.friSat; // Fri, Sat return OPENING.restaurant.default; } @@ -393,7 +476,6 @@ function canForwardToHuman() { function forwardToHumanTwiml() { const vr = buildTwiml(); - // ✅ PROMPTS: operator transfer sayIt(vr, t("step9_fallback_transfer_operator.main")); vr.dial({}, HUMAN_FORWARD_TO); return vr.toString(); @@ -463,7 +545,7 @@ async function calendarHasMarkerOnDate(calendar, dateISO, markerLower) { function isPromoEligibleByDay(dateISO) { const d = new Date(`${dateISO}T00:00:00`); const day = d.getDay(); - const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; + const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; // Tue-Sun excl Fri if (!allowedDay) return false; if (HOLIDAYS_SET.has(dateISO)) return false; return true; @@ -498,29 +580,25 @@ async function getLockedTables(calendar, dateISO) { // ======================= TABLE ALLOCATION ======================= function buildCandidates(area) { - const singles = TABLES - .filter((tt) => tt.area === area) - .map((tt) => ({ - displayId: tt.id, - locks: [tt.id], - min: tt.min, - max: tt.max, - area: tt.area, - notes: tt.notes || "", - kind: "single", - })); - - const combos = TABLE_COMBINATIONS - .filter((c) => c.area === area) - .map((c) => ({ - displayId: c.displayId, - locks: c.replaces.slice(), - min: c.min, - max: c.max, - area: c.area, - notes: c.notes || "", - kind: "combo", - })); + const singles = TABLES.filter((tt) => tt.area === area).map((tt) => ({ + displayId: tt.id, + locks: [tt.id], + min: tt.min, + max: tt.max, + area: tt.area, + notes: tt.notes || "", + kind: "single", + })); + + const combos = TABLE_COMBINATIONS.filter((c) => c.area === area).map((c) => ({ + displayId: c.displayId, + locks: c.replaces.slice(), + min: c.min, + max: c.max, + area: c.area, + notes: c.notes || "", + kind: "combo", + })); return singles.concat(combos); } @@ -707,7 +785,6 @@ app.post("/voice", async (req, res) => { case 1: { if (emptySpeech) { resetRetries(session); - // ✅ PROMPTS gatherSpeech(vr, t("step1_welcome_name.main")); break; } @@ -715,8 +792,6 @@ app.post("/voice", async (req, res) => { session.name = speech.trim().slice(0, 60); resetRetries(session); session.step = 2; - - // ✅ PROMPTS gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); break; } @@ -724,13 +799,11 @@ app.post("/voice", async (req, res) => { case 2: { if (emptySpeech) { if (bumpRetries(session) > 2) { - // ✅ PROMPTS + operator transfer (same logic) sayIt(vr, t("step3_confirm_date_ask_time.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - // ✅ PROMPTS (error name prompt used as generic “repeat”) gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } @@ -743,7 +816,6 @@ app.post("/voice", async (req, res) => { sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - // ✅ PROMPTS gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } @@ -751,23 +823,18 @@ app.post("/voice", async (req, res) => { session.dateISO = dateISO; resetRetries(session); session.step = 3; - - // ✅ PROMPTS gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO })); break; } case 3: { - // FIX: no calendar calls here (logic unchanged) if (emptySpeech) { if (bumpRetries(session) > 2) { - // ✅ PROMPTS (time error) sayIt(vr, t("step4_confirm_time_ask_party_size.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - // ✅ PROMPTS gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); break; } @@ -780,19 +847,17 @@ app.post("/voice", async (req, res) => { sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - // ✅ PROMPTS gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); break; } session.time24 = time24; - const { bookingType, autoConfirm, reason } = deriveBookingTypeAndConfirm(session.dateISO, session.time24); + const { bookingType, autoConfirm } = deriveBookingTypeAndConfirm(session.dateISO, session.time24); session.bookingType = bookingType; session.autoConfirm = autoConfirm; if (bookingType === "closed") { - // ✅ PROMPTS sayIt(vr, t("step4_confirm_time_ask_party_size.outsideHours.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare durante l'orario di apertura. Grazie."); @@ -800,17 +865,13 @@ app.post("/voice", async (req, res) => { } if (bookingType === "operator") { - // (nessun prompt specifico in file per questa frase: lasciato invariato) sayIt(vr, "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo."); } else if (bookingType === "drinks") { - // ✅ PROMPTS (kitchen closed) sayIt(vr, t("step4_confirm_time_ask_party_size.kitchenClosed.main")); } resetRetries(session); session.step = 4; - - // ✅ PROMPTS gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 })); break; } @@ -818,13 +879,11 @@ app.post("/voice", async (req, res) => { case 4: { if (emptySpeech) { if (bumpRetries(session) > 2) { - // ✅ PROMPTS sayIt(vr, t("step5_party_size_ask_notes.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - // ✅ PROMPTS gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; } @@ -837,7 +896,6 @@ app.post("/voice", async (req, res) => { sayIt(vr, "Grazie."); break; } - // ✅ PROMPTS gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; } @@ -845,24 +903,19 @@ app.post("/voice", async (req, res) => { session.people = people; resetRetries(session); session.step = 5; - - // ✅ PROMPTS gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); break; } case 5: { - // Notes collection (logic unchanged) if (emptySpeech) { if (bumpRetries(session) > 2) { session.specialRequestsRaw = "nessuna"; resetRetries(session); session.step = 6; - // (preorder prompt not in PROMPTS: left unchanged) gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); break; } - // ✅ PROMPTS gatherSpeech(vr, t("step6_collect_notes.error")); break; } @@ -870,14 +923,11 @@ app.post("/voice", async (req, res) => { session.specialRequestsRaw = speech.trim().slice(0, 200); resetRetries(session); session.step = 6; - - // (preorder prompt not in PROMPTS: left unchanged) gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); break; } case 6: { - // Preorder choice (logic unchanged; no matching prompts in provided file) if (emptySpeech) { if (bumpRetries(session) > 2) { session.preorderChoiceKey = null; @@ -944,13 +994,11 @@ app.post("/voice", async (req, res) => { } case 8: { - // Area selection (logic unchanged; no matching prompts in provided file) if (emptySpeech) { if (bumpRetries(session) > 2) { session.area = "inside"; resetRetries(session); session.step = 10; - // ✅ PROMPTS for WhatsApp request gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } @@ -967,7 +1015,6 @@ app.post("/voice", async (req, res) => { session.pendingOutsideConfirm = false; resetRetries(session); session.step = 10; - // ✅ PROMPTS gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } @@ -976,7 +1023,6 @@ app.post("/voice", async (req, res) => { session.pendingOutsideConfirm = false; resetRetries(session); session.step = 10; - // ✅ PROMPTS gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } @@ -986,7 +1032,6 @@ app.post("/voice", async (req, res) => { session.pendingOutsideConfirm = false; resetRetries(session); session.step = 10; - // ✅ PROMPTS gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } @@ -1001,7 +1046,6 @@ app.post("/voice", async (req, res) => { session.area = "inside"; resetRetries(session); session.step = 10; - // ✅ PROMPTS gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } @@ -1022,22 +1066,18 @@ app.post("/voice", async (req, res) => { session.area = "inside"; resetRetries(session); session.step = 10; - // ✅ PROMPTS gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } case 10: { - // Phone / WhatsApp + FINAL step calendar if (emptySpeech) { if (bumpRetries(session) > 2) { - // ✅ PROMPTS + operator transfer (logic unchanged) sayIt(vr, t("step7_whatsapp_number.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, "Puoi richiamare più tardi. Grazie."); break; } - // ✅ PROMPTS gatherSpeech(vr, t("step7_whatsapp_number.error")); break; } @@ -1050,7 +1090,6 @@ app.post("/voice", async (req, res) => { sayIt(vr, "Grazie."); break; } - // ✅ PROMPTS (spokeTooFast as “repeat”) gatherSpeech(vr, t("step7_whatsapp_number.spokeTooFast")); break; } @@ -1116,12 +1155,7 @@ app.post("/voice", async (req, res) => { break; } - const chosen = allocateTable({ - area: session.area, - people: session.people, - lockedSet, - }); - + const chosen = allocateTable({ area: session.area, people: session.people, lockedSet }); if (!chosen) { sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); @@ -1185,7 +1219,6 @@ app.post("/voice", async (req, res) => { } } - // ✅ PROMPTS: success + goodbye (no business logic change) sayIt(vr, t("step9_success.main")); sayIt(vr, t("step9_success.goodbye")); From de81540c977181c5eb0d09988a04087cfea5b5e9 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 03:00:20 +0100 Subject: [PATCH 035/143] Improve date parsing and add back command handling Enhanced date parsing and added back command functionality. --- app.js | 271 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 216 insertions(+), 55 deletions(-) diff --git a/app.js b/app.js index eec5ba9c2a..90775b3dd3 100644 --- a/app.js +++ b/app.js @@ -185,21 +185,129 @@ function nowLocal() { return new Date(); } -/** - * ✅ DATE PARSER ENHANCED: - * - oggi, domani - * - 30/12, 30-12, 30 12 (+ optional year) - * - 2025-12-30 - * - 30 dicembre / martedì 30 dicembre - * - questo venerdì / prossimo venerdì / venerdì prossimo / venerdì - */ -function parseDateIT(speech) { - const t0 = normalizeText(speech); - const t = t0 - .replace(/[,\.]/g, " ") +// ----------------------- BACK / EDIT COMMANDS ----------------------- +function isBackCommand(speech) { + const t = normalizeText(speech); + return ( + t.includes("indietro") || + t.includes("torna indietro") || + t.includes("tornare indietro") || + t.includes("modifica") || + t.includes("errore") || + t.includes("ho sbagliato") || + t.includes("sbagliato") + ); +} + +function goBack(session) { + if (!session || typeof session.step !== "number") return; + if (session.step <= 1) return; + + // Mappa minima coerente col tuo flusso: + // 1 nome -> 2 data -> 3 ora -> 4 pax -> 5 note -> 6 preordine -> 8 area -> 10 telefono + if (session.step === 2) session.step = 1; + else if (session.step === 3) session.step = 2; + else if (session.step === 4) session.step = 3; + else if (session.step === 5) session.step = 4; + else if (session.step === 6) session.step = 5; + else if (session.step === 8) session.step = 6; + else if (session.step === 10) session.step = 8; + else session.step = Math.max(1, session.step - 1); +} + +function promptForStep(vr, session) { + switch (session.step) { + case 1: + gatherSpeech(vr, t("step1_welcome_name.main")); + return true; + case 2: + gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); + return true; + case 3: + gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO || "" })); + return true; + case 4: + gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); + return true; + case 5: + gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); + return true; + case 6: + // qui nel tuo flusso è hardcoded il preordine + gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); + return true; + case 8: + gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + return true; + case 10: + gatherSpeech(vr, t("step7_whatsapp_number.main")); + return true; + default: + gatherSpeech(vr, t("step1_welcome_name.short")); + return true; + } +} + +// ----------------------- DATE “SUPER ROBUST” ----------------------- +function parseItalianNumberToInt(text) { + const t = normalizeText(text) + .replace(/[-,\.]/g, " ") .replace(/\s+/g, " ") .trim(); + const direct = { + uno: 1, una: 1, primo: 1, + due: 2, + tre: 3, + quattro: 4, + cinque: 5, + sei: 6, + sette: 7, + otto: 8, + nove: 9, + dieci: 10, + undici: 11, + dodici: 12, + tredici: 13, + quattordici: 14, + quindici: 15, + sedici: 16, + diciassette: 17, + diciotto: 18, + diciannove: 19, + venti: 20, + trenta: 30, + }; + if (direct[t] != null) return direct[t]; + + // ventuno/ventidue/... (supporto pratico) + const cleaned = t.replace(/’/g, "'").replace(/'/g, ""); + const units = { + uno: 1, una: 1, due: 2, tre: 3, quattro: 4, cinque: 5, sei: 6, sette: 7, otto: 8, nove: 9 + }; + + if (cleaned.startsWith("venti")) { + const tail = cleaned.slice("venti".length).trim(); + if (!tail) return 20; + if (units[tail] != null) return 20 + units[tail]; + if (tail === "tre") return 23; + } + + if (cleaned.startsWith("trenta")) { + const tail = cleaned.slice("trenta".length).trim(); + if (!tail) return 30; + if (tail === "uno" || tail === "una") return 31; + } + + const m = cleaned.match(/\b(\d{1,2})\b/); + if (m) return Number(m[1]); + + return null; +} + +function parseDateIT(speech) { + const t0 = normalizeText(speech); + const t = t0.replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); const today = nowLocal(); if (t.includes("oggi")) return toISODate(today); @@ -208,11 +316,22 @@ function parseDateIT(speech) { return toISODate(d); } - // 1) ISO yyyy-mm-dd + // "tra 3 giorni" / "fra due giorni" + const rel = t.match(/\b(tra|fra)\s+(.+?)\s+giorn[io]\b/); + if (rel) { + const n = parseItalianNumberToInt(rel[2]); + if (n && n > 0) { + const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + d.setDate(d.getDate() + n); + return toISODate(d); + } + } + + // ISO yyyy-mm-dd const iso = t.match(/(\d{4})-(\d{2})-(\d{2})/); if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; - // 2) dd/mm(/yyyy) or dd-mm(-yyyy) + // dd/mm(/yyyy) or dd-mm(-yyyy) const dmY = t.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); if (dmY) { let dd = Number(dmY[1]); @@ -225,7 +344,7 @@ function parseDateIT(speech) { } } - // 3) dd mm (yyyy) -> es: "30 12" o "30 12 2025" + // dd mm (yyyy) -> "30 12" / "30 12 2025" const dmSpace = t.match(/\b(\d{1,2})\s+(\d{1,2})(?:\s+(\d{2,4}))?\b/); if (dmSpace) { let dd = Number(dmSpace[1]); @@ -238,7 +357,7 @@ function parseDateIT(speech) { } } - // 4) weekday phrases: "questo venerdì", "prossimo venerdì", "venerdì prossimo", "venerdì" + // weekday phrases: "questo venerdì", "prossimo venerdì", "venerdì prossimo", "venerdì" const weekdayMap = { domenica: 0, lunedi: 1, "lunedì": 1, @@ -250,34 +369,32 @@ function parseDateIT(speech) { }; const hasQuesto = /\b(questo|questa|sto|sta)\b/.test(t); - const hasProssimo = /\b(prossimo|prossima)\b/.test(t); - const hasGiornoProssimoPost = /\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\s+prossim[oa]\b/.test(t); + const hasProssimo = /\b(prossimo|prossima)\b/.test(t) || + /\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\s+prossim[oa]\b/.test(t); const weekdayMatch = t.match(/\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\b/); if (weekdayMatch) { const wdToken = weekdayMatch[1]; const target = weekdayMap[wdToken]; - const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const current = d.getDay(); let diff = (target - current + 7) % 7; - const wantsNext = hasProssimo || hasGiornoProssimoPost; - if (wantsNext) { + if (hasProssimo) { if (diff === 0) diff = 7; else diff += 7; } else if (hasQuesto) { - // allow today if same day; otherwise upcoming (diff ok) + // ok } else { - // bare weekday -> upcoming occurrence (today if same day) + // ok } d.setDate(d.getDate() + diff); return toISODate(d); } - // 5) "30 dicembre" / "martedì 30 dicembre" + // "trenta dicembre" / "il primo gennaio" / "martedì trenta dicembre" const months = { gennaio: 1, febbraio: 2, marzo: 3, aprile: 4, maggio: 5, giugno: 6, luglio: 7, agosto: 8, settembre: 9, ottobre: 10, novembre: 11, dicembre: 12, @@ -285,18 +402,18 @@ function parseDateIT(speech) { const cleaned = t .replace(/\b(lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato|domenica)\b/g, " ") - .replace(/\b(questo|questa|prossimo|prossima|il|lo|la|per|di)\b/g, " ") + .replace(/\b(questo|questa|prossimo|prossima|il|lo|la|per|di|del|dello|della)\b/g, " ") .replace(/\s+/g, " ") .trim(); - const m = cleaned.match(/\b(\d{1,2})\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); + const m = cleaned.match(/\b(.+?)\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); if (m) { - const dd = Number(m[1]); + const dd = parseItalianNumberToInt(m[1]); const mm = months[m[2]]; let yy = m[3] ? Number(m[3]) : today.getFullYear(); if (yy < 100) yy += 2000; - if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { + if (dd && dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { const d = new Date(yy, mm - 1, dd); return toISODate(d); } @@ -359,6 +476,7 @@ function getPreorderOptionByKey(key) { return PREORDER_OPTIONS.find((o) => o.key === key) || null; } +// ----------------------- PHONE (DEFAULT +39, NEVER DROP) ----------------------- function isValidPhoneE164(s) { return /^\+\d{8,15}$/.test(String(s || "").trim()); } @@ -367,12 +485,36 @@ function hasValidWaAddress(s) { return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); } +function normalizePhoneWithDefaultItaly(rawSpeech) { + if (!rawSpeech) return null; + + let s = String(rawSpeech).replace(/[^\d+]/g, ""); + if (!s) return null; + + if (s.startsWith("00")) s = "+" + s.slice(2); + + if (s.startsWith("+")) { + const digits = s.slice(1).replace(/[^\d]/g, ""); + if (!digits) return null; + return "+" + digits; + } + + const digitsOnly = s.replace(/[^\d]/g, ""); + if (!digitsOnly) return null; + + if (digitsOnly.startsWith("39")) return "+" + digitsOnly; + return "+39" + digitsOnly; +} + function extractPhoneFromSpeech(speech) { - const t = normalizeText(speech).replace(/[^\d+]/g, ""); - if (isValidPhoneE164(t)) return t; - const digits = normalizeText(speech).replace(/[^\d]/g, ""); - if (digits.length >= 9 && digits.length <= 11) return `+39${digits}`; - return null; + const p = normalizePhoneWithDefaultItaly(speech); + if (!p) return null; + + const digits = p.replace(/[^\d]/g, ""); + if (digits.length < 8) return null; + + if (digits.length > 15) return "+" + digits.slice(0, 15); + return p; } // ======================= TIME HELPERS ======================= @@ -781,6 +923,14 @@ app.post("/voice", async (req, res) => { const emptySpeech = !normalizeText(speech); + // ✅ Comandi: "indietro / modifica / errore" + if (!emptySpeech && isBackCommand(speech)) { + resetRetries(session); + goBack(session); + promptForStep(vr, session); + return res.type("text/xml").send(vr.toString()); + } + switch (session.step) { case 1: { if (emptySpeech) { @@ -1071,33 +1221,37 @@ app.post("/voice", async (req, res) => { } case 10: { + // ✅ Step telefono: mai interrompere la chiamata, e prenotazione sempre su Calendar if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, t("step7_whatsapp_number.error")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + // dopo 3 tentativi vuoto -> prenotazione parziale (senza WA) + session.phone = null; + session.waTo = null; + resetRetries(session); + } else { + gatherSpeech(vr, t("step7_whatsapp_number.error")); break; } - gatherSpeech(vr, t("step7_whatsapp_number.error")); - break; - } - - const phone = extractPhoneFromSpeech(speech); - if (!phone || !isValidPhoneE164(phone)) { - if (bumpRetries(session) > 2) { - sayIt(vr, t("step7_whatsapp_number.error")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Grazie."); - break; + } else { + const phone = extractPhoneFromSpeech(speech); + + if (!phone || !isValidPhoneE164(phone)) { + if (bumpRetries(session) <= 2) { + gatherSpeech(vr, t("step7_whatsapp_number.spokeTooFast")); + break; + } + // dopo 3 tentativi -> prenotazione parziale + session.phone = null; + session.waTo = null; + resetRetries(session); + } else { + session.phone = phone; + session.waTo = `whatsapp:${phone}`; + resetRetries(session); } - gatherSpeech(vr, t("step7_whatsapp_number.spokeTooFast")); - break; } - session.phone = phone; - session.waTo = `whatsapp:${phone}`; - resetRetries(session); - + // Calcolo durata / disclaimer const d = new Date(`${session.dateISO}T00:00:00`); session.durationMinutes = getDurationMinutes(session.people, d); @@ -1167,6 +1321,7 @@ app.post("/voice", async (req, res) => { session.tableLocks = chosen.locks; session.tableNotes = chosen.notes || null; + // ✅ Calendar SEMPRE, anche senza telefono/WA try { await createBookingEvent({ callSid, @@ -1197,7 +1352,8 @@ app.post("/voice", async (req, res) => { break; } - if (session.autoConfirm) { + // ✅ WhatsApp SOLO se WA valido + if (session.autoConfirm && session.waTo && hasValidWaAddress(session.waTo)) { try { await sendWhatsAppConfirmation({ waTo: session.waTo, @@ -1219,7 +1375,12 @@ app.post("/voice", async (req, res) => { } } - sayIt(vr, t("step9_success.main")); + // messaggio finale: se non c'è WA, non promettere WhatsApp + if (session.waTo && hasValidWaAddress(session.waTo)) { + sayIt(vr, t("step9_success.main")); + } else { + sayIt(vr, "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi il numero con calma."); + } sayIt(vr, t("step9_success.goodbye")); vr.hangup(); From 2b77e008ab39d0c0035fa5150812c552b59c871e Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 08:35:06 +0100 Subject: [PATCH 036/143] Update app.js --- app.js | 277 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 193 insertions(+), 84 deletions(-) diff --git a/app.js b/app.js index 90775b3dd3..29978fdeac 100644 --- a/app.js +++ b/app.js @@ -26,9 +26,7 @@ const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "false").toLowerCase const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; const HOLIDAYS_YYYY_MM_DD = process.env.HOLIDAYS_YYYY_MM_DD || ""; -const HOLIDAYS_SET = new Set( - HOLIDAYS_YYYY_MM_DD.split(",").map((s) => s.trim()).filter(Boolean) -); +const HOLIDAYS_SET = new Set(HOLIDAYS_YYYY_MM_DD.split(",").map((s) => s.trim()).filter(Boolean)); const twilioClient = TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) : null; @@ -73,7 +71,12 @@ const PREORDER_OPTIONS = [ { key: "apericena", label: "Apericena", priceEUR: null, constraints: {} }, { key: "dopocena", label: "Dopocena (dopo le 22:30)", priceEUR: null, constraints: { minTime: "22:30" } }, { key: "piatto_apericena", label: "Piatto Apericena", priceEUR: 25, constraints: {} }, - { key: "piatto_apericena_promo", label: "Piatto Apericena in promo (previa registrazione)", priceEUR: null, constraints: { promoOnly: true } }, + { + key: "piatto_apericena_promo", + label: "Piatto Apericena in promo (previa registrazione)", + priceEUR: null, + constraints: { promoOnly: true }, + }, ]; // ======================= CONFIG: TABLES ======================= @@ -203,7 +206,7 @@ function goBack(session) { if (!session || typeof session.step !== "number") return; if (session.step <= 1) return; - // Mappa minima coerente col tuo flusso: + // Mappa coerente col flusso: // 1 nome -> 2 data -> 3 ora -> 4 pax -> 5 note -> 6 preordine -> 8 area -> 10 telefono if (session.step === 2) session.step = 1; else if (session.step === 3) session.step = 2; @@ -219,36 +222,46 @@ function promptForStep(vr, session) { switch (session.step) { case 1: gatherSpeech(vr, t("step1_welcome_name.main")); - return true; + return; case 2: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); - return true; + return; case 3: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO || "" })); - return true; + return; case 4: gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); - return true; + return; case 5: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); - return true; + return; case 6: - // qui nel tuo flusso è hardcoded il preordine - gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); - return true; + gatherSpeech( + vr, + "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." + ); + return; case 8: gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - return true; + return; case 10: gatherSpeech(vr, t("step7_whatsapp_number.main")); - return true; + return; default: gatherSpeech(vr, t("step1_welcome_name.short")); - return true; + return; } } -// ----------------------- DATE “SUPER ROBUST” ----------------------- +/** + * ✅ DATE PARSER ENHANCED: + * - oggi, domani + * - tra/fra X giorni (anche in parole: "tre", "due"...) + * - 30/12, 30-12, 30 12 (+ optional year) + * - 2025-12-30 + * - 30 dicembre / il primo gennaio / trenta dicembre + * - questo venerdì / venerdì prossimo + */ function parseItalianNumberToInt(text) { const t = normalizeText(text) .replace(/[-,\.]/g, " ") @@ -256,7 +269,9 @@ function parseItalianNumberToInt(text) { .trim(); const direct = { - uno: 1, una: 1, primo: 1, + uno: 1, + una: 1, + primo: 1, due: 2, tre: 3, quattro: 4, @@ -280,17 +295,13 @@ function parseItalianNumberToInt(text) { }; if (direct[t] != null) return direct[t]; - // ventuno/ventidue/... (supporto pratico) const cleaned = t.replace(/’/g, "'").replace(/'/g, ""); - const units = { - uno: 1, una: 1, due: 2, tre: 3, quattro: 4, cinque: 5, sei: 6, sette: 7, otto: 8, nove: 9 - }; + const units = { uno: 1, una: 1, due: 2, tre: 3, quattro: 4, cinque: 5, sei: 6, sette: 7, otto: 8, nove: 9 }; if (cleaned.startsWith("venti")) { const tail = cleaned.slice("venti".length).trim(); if (!tail) return 20; if (units[tail] != null) return 20 + units[tail]; - if (tail === "tre") return 23; } if (cleaned.startsWith("trenta")) { @@ -316,7 +327,6 @@ function parseDateIT(speech) { return toISODate(d); } - // "tra 3 giorni" / "fra due giorni" const rel = t.match(/\b(tra|fra)\s+(.+?)\s+giorn[io]\b/); if (rel) { const n = parseItalianNumberToInt(rel[2]); @@ -327,11 +337,9 @@ function parseDateIT(speech) { } } - // ISO yyyy-mm-dd const iso = t.match(/(\d{4})-(\d{2})-(\d{2})/); if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; - // dd/mm(/yyyy) or dd-mm(-yyyy) const dmY = t.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); if (dmY) { let dd = Number(dmY[1]); @@ -344,7 +352,6 @@ function parseDateIT(speech) { } } - // dd mm (yyyy) -> "30 12" / "30 12 2025" const dmSpace = t.match(/\b(\d{1,2})\s+(\d{1,2})(?:\s+(\d{2,4}))?\b/); if (dmSpace) { let dd = Number(dmSpace[1]); @@ -357,25 +364,31 @@ function parseDateIT(speech) { } } - // weekday phrases: "questo venerdì", "prossimo venerdì", "venerdì prossimo", "venerdì" const weekdayMap = { domenica: 0, - lunedi: 1, "lunedì": 1, - martedi: 2, "martedì": 2, - mercoledi: 3, "mercoledì": 3, - giovedi: 4, "giovedì": 4, - venerdi: 5, "venerdì": 5, + lunedi: 1, + "lunedì": 1, + martedi: 2, + "martedì": 2, + mercoledi: 3, + "mercoledì": 3, + giovedi: 4, + "giovedì": 4, + venerdi: 5, + "venerdì": 5, sabato: 6, }; const hasQuesto = /\b(questo|questa|sto|sta)\b/.test(t); - const hasProssimo = /\b(prossimo|prossima)\b/.test(t) || + const hasProssimo = + /\b(prossimo|prossima)\b/.test(t) || /\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\s+prossim[oa]\b/.test(t); - const weekdayMatch = t.match(/\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\b/); + const weekdayMatch = t.match( + /\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\b/ + ); if (weekdayMatch) { - const wdToken = weekdayMatch[1]; - const target = weekdayMap[wdToken]; + const target = weekdayMap[weekdayMatch[1]]; const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const current = d.getDay(); @@ -394,10 +407,19 @@ function parseDateIT(speech) { return toISODate(d); } - // "trenta dicembre" / "il primo gennaio" / "martedì trenta dicembre" const months = { - gennaio: 1, febbraio: 2, marzo: 3, aprile: 4, maggio: 5, giugno: 6, - luglio: 7, agosto: 8, settembre: 9, ottobre: 10, novembre: 11, dicembre: 12, + gennaio: 1, + febbraio: 2, + marzo: 3, + aprile: 4, + maggio: 5, + giugno: 6, + luglio: 7, + agosto: 8, + settembre: 9, + ottobre: 10, + novembre: 11, + dicembre: 12, }; const cleaned = t @@ -406,7 +428,9 @@ function parseDateIT(speech) { .replace(/\s+/g, " ") .trim(); - const m = cleaned.match(/\b(.+?)\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); + const m = cleaned.match( + /\b(.+?)\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/ + ); if (m) { const dd = parseItalianNumberToInt(m[1]); const mm = months[m[2]]; @@ -476,45 +500,123 @@ function getPreorderOptionByKey(key) { return PREORDER_OPTIONS.find((o) => o.key === key) || null; } -// ----------------------- PHONE (DEFAULT +39, NEVER DROP) ----------------------- +// ======================= PHONE HELPERS ======================= function isValidPhoneE164(s) { return /^\+\d{8,15}$/.test(String(s || "").trim()); } - function hasValidWaAddress(s) { return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); } -function normalizePhoneWithDefaultItaly(rawSpeech) { - if (!rawSpeech) return null; +function speechToDigitsIT(raw) { + const t = normalizeText(raw); + + const map = { + zero: "0", + uno: "1", + una: "1", + due: "2", + tre: "3", + quattro: "4", + cinque: "5", + sei: "6", + sette: "7", + otto: "8", + nove: "9", + }; + + const tokens = t + .replace(/[^a-z0-9+\s]/g, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .filter(Boolean); - let s = String(rawSpeech).replace(/[^\d+]/g, ""); - if (!s) return null; + let out = ""; - if (s.startsWith("00")) s = "+" + s.slice(2); + for (let i = 0; i < tokens.length; i++) { + const w = tokens[i]; - if (s.startsWith("+")) { - const digits = s.slice(1).replace(/[^\d]/g, ""); - if (!digits) return null; - return "+" + digits; - } + if (/^\d+$/.test(w)) { + out += w; + continue; + } + + if (w === "+" || w === "piu" || w === "più") { + out += "+"; + continue; + } + + if (w === "doppio" || w === "triplo") { + const next = tokens[i + 1]; + const digit = map[next] || (next && /^\d$/.test(next) ? next : null); + if (digit) { + out += w === "doppio" ? digit + digit : digit + digit + digit; + i++; + continue; + } + } - const digitsOnly = s.replace(/[^\d]/g, ""); - if (!digitsOnly) return null; + if (map[w]) { + out += map[w]; + continue; + } + } - if (digitsOnly.startsWith("39")) return "+" + digitsOnly; - return "+39" + digitsOnly; + return out; } function extractPhoneFromSpeech(speech) { - const p = normalizePhoneWithDefaultItaly(speech); - if (!p) return null; + if (!speech) return null; + + // 1) prova direttamente dalle cifre + let raw = String(speech).replace(/[^\d+]/g, ""); + if (raw) { + if (raw.startsWith("00")) raw = "+" + raw.slice(2); + + if (raw.startsWith("+")) { + const digits = raw.slice(1).replace(/\D/g, ""); + const e164 = "+" + digits; + if (isValidPhoneE164(e164)) return e164; + } else { + const digits = raw.replace(/\D/g, ""); + if (digits.length >= 8 && digits.length <= 15) { + if (digits.startsWith("39")) { + const e164 = "+" + digits; + if (isValidPhoneE164(e164)) return e164; + } else { + const e164 = "+39" + digits; + if (isValidPhoneE164(e164)) return e164; + } + } + } + } + + // 2) prova a convertire parole -> cifre + const fromWords = speechToDigitsIT(speech); + if (!fromWords) return null; + + let s = String(fromWords).replace(/[^\d+]/g, ""); + if (!s) return null; + + if (s.startsWith("00")) s = "+" + s.slice(2); + + if (s.startsWith("+")) { + const digits = s.slice(1).replace(/\D/g, ""); + const e164 = "+" + digits; + return isValidPhoneE164(e164) ? e164 : null; + } - const digits = p.replace(/[^\d]/g, ""); - if (digits.length < 8) return null; + const digits = s.replace(/\D/g, ""); + if (!digits) return null; - if (digits.length > 15) return "+" + digits.slice(0, 15); - return p; + if (digits.startsWith("39")) { + const e164 = "+" + digits; + return isValidPhoneE164(e164) ? e164 : null; + } + + const e164 = "+39" + digits; + return isValidPhoneE164(e164) ? e164 : null; } // ======================= TIME HELPERS ======================= @@ -528,7 +630,7 @@ function isTimeAtOrAfter(time24, minTime24) { } function getRestaurantWindowForDay(day) { - if (day === 5 || day === 6) return OPENING.restaurant.friSat; // Fri, Sat + if (day === 5 || day === 6) return OPENING.restaurant.friSat; return OPENING.restaurant.default; } @@ -923,7 +1025,7 @@ app.post("/voice", async (req, res) => { const emptySpeech = !normalizeText(speech); - // ✅ Comandi: "indietro / modifica / errore" + // ✅ Comandi vocali: "indietro / modifica / errore" if (!emptySpeech && isBackCommand(speech)) { resetRetries(session); goBack(session); @@ -1015,7 +1117,10 @@ app.post("/voice", async (req, res) => { } if (bookingType === "operator") { - sayIt(vr, "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo."); + sayIt( + vr, + "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo." + ); } else if (bookingType === "drinks") { sayIt(vr, t("step4_confirm_time_ask_party_size.kitchenClosed.main")); } @@ -1063,7 +1168,10 @@ app.post("/voice", async (req, res) => { session.specialRequestsRaw = "nessuna"; resetRetries(session); session.step = 6; - gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); + gatherSpeech( + vr, + "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." + ); break; } gatherSpeech(vr, t("step6_collect_notes.error")); @@ -1073,7 +1181,10 @@ app.post("/voice", async (req, res) => { session.specialRequestsRaw = speech.trim().slice(0, 200); resetRetries(session); session.step = 6; - gatherSpeech(vr, "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno."); + gatherSpeech( + vr, + "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, piatto apericena promo. Se non vuoi, dì nessuno." + ); break; } @@ -1221,17 +1332,17 @@ app.post("/voice", async (req, res) => { } case 10: { - // ✅ Step telefono: mai interrompere la chiamata, e prenotazione sempre su Calendar + // ✅ Step telefono: NON deve mai bloccare Calendar. + // - Se il numero non viene capito: riprova massimo 2 volte. + // - Poi prosegui senza numero (prenotazione parziale su Calendar). if (emptySpeech) { - if (bumpRetries(session) > 2) { - // dopo 3 tentativi vuoto -> prenotazione parziale (senza WA) - session.phone = null; - session.waTo = null; - resetRetries(session); - } else { + if (bumpRetries(session) <= 2) { gatherSpeech(vr, t("step7_whatsapp_number.error")); break; } + session.phone = null; + session.waTo = null; + resetRetries(session); } else { const phone = extractPhoneFromSpeech(speech); @@ -1240,7 +1351,6 @@ app.post("/voice", async (req, res) => { gatherSpeech(vr, t("step7_whatsapp_number.spokeTooFast")); break; } - // dopo 3 tentativi -> prenotazione parziale session.phone = null; session.waTo = null; resetRetries(session); @@ -1251,14 +1361,11 @@ app.post("/voice", async (req, res) => { } } - // Calcolo durata / disclaimer const d = new Date(`${session.dateISO}T00:00:00`); session.durationMinutes = getDurationMinutes(session.people, d); const outsideDisclaimer = - session.area === "outside" - ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." - : null; + session.area === "outside" ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." : null; let preorderPriceText = null; if (session.preorderChoiceKey) { @@ -1321,7 +1428,7 @@ app.post("/voice", async (req, res) => { session.tableLocks = chosen.locks; session.tableNotes = chosen.notes || null; - // ✅ Calendar SEMPRE, anche senza telefono/WA + // ✅ Calendar SEMPRE (anche prenotazione parziale) try { await createBookingEvent({ callSid, @@ -1352,7 +1459,7 @@ app.post("/voice", async (req, res) => { break; } - // ✅ WhatsApp SOLO se WA valido + // ✅ WhatsApp SOLO se numero valido (e Calendar ok) if (session.autoConfirm && session.waTo && hasValidWaAddress(session.waTo)) { try { await sendWhatsAppConfirmation({ @@ -1375,11 +1482,13 @@ app.post("/voice", async (req, res) => { } } - // messaggio finale: se non c'è WA, non promettere WhatsApp if (session.waTo && hasValidWaAddress(session.waTo)) { sayIt(vr, t("step9_success.main")); } else { - sayIt(vr, "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi il numero con calma."); + sayIt( + vr, + "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi con calma il numero." + ); } sayIt(vr, t("step9_success.goodbye")); From cf329c77180dd55d2aec2c6d6d7a1e98371bcb66 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 08:41:33 +0100 Subject: [PATCH 037/143] Update prompts.js --- prompts.js | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/prompts.js b/prompts.js index f414c95201..3d5fae3aa4 100644 --- a/prompts.js +++ b/prompts.js @@ -5,18 +5,21 @@ */ const PROMPTS = { + // STEP 1 — Benvenuto + Nome step1_welcome_name: { main: "Buonasera, TuttiBrilli Enoteca. Ti do una mano con la prenotazione. Partiamo dal nome: come ti chiami?", short: "Buonasera, TuttiBrilli. Come ti chiami?", error: "Perdonami, è andato un po’ via l’audio. Mi ripeti il nome?" }, + // STEP 2 — Conferma nome + Data step2_confirm_name_ask_date: { main: "Perfetto, piacere {{name}}. Per che giorno pensavi?", short: "Perfetto {{name}}. Per che giorno?", error: "Voglio essere sicuro di aver capito: il nome è {{guess}}?" }, + // STEP 3 — Conferma data + Ora step3_confirm_date_ask_time: { main: "Ok, {{dateLabel}}. A che ora ti va di venire?", short: "Ok {{dateLabel}}. A che ora?", @@ -28,6 +31,7 @@ const PROMPTS = { todayVariant: "Perfetto, per questa sera. A che ora?" }, + // STEP 4 — Conferma ora + Persone step4_confirm_time_ask_party_size: { main: "Perfetto, alle {{time}}. In quanti sarete?", short: "Ok {{time}}. In quanti?", @@ -43,15 +47,20 @@ const PROMPTS = { afterDinner: "Perfetto, quindi dopocena. Quante persone sarete?" }, + // STEP 5 — Conferma persone + Allergie/Richieste step5_party_size_ask_notes: { main: "Perfetto, {{partySize}} persone. C’è qualche allergia, intolleranza o richiesta particolare?", short: "Ok {{partySize}}. Allergie o richieste?", error: "Scusami, non ho capito il numero. Me lo ripeti?", - largeGroupPositive: "Ok, {{partySize}} persone. Ci organizziamo senza problemi. C’è qualche allergia o richiesta particolare?", - checkingAvailability: "Un attimo solo che controllo la disponibilità per {{partySize}} persone. Ci metto pochissimo.", - noAvailability: "Ti dico la verità: per {{partySize}} persone a quell’orario siamo al completo. Se vuoi, proviamo un altro orario o un altro giorno." + largeGroupPositive: + "Ok, {{partySize}} persone. Ci organizziamo senza problemi. C’è qualche allergia o richiesta particolare?", + checkingAvailability: + "Un attimo solo che controllo la disponibilità per {{partySize}} persone. Ci metto pochissimo.", + noAvailability: + "Ti dico la verità: per {{partySize}} persone a quell’orario siamo al completo. Se vuoi, proviamo un altro orario o un altro giorno." }, + // STEP 6 — Raccolta dettagli allergie/richieste (senza ripetere la domanda lunga) step6_collect_notes: { main: "Dimmi pure cosa devo segnalare.", short: "Cosa devo segnare?", @@ -60,6 +69,7 @@ const PROMPTS = { noneClose: "Perfetto, tutto ok." }, + // STEP 7 — WhatsApp (qui, non prima) step7_whatsapp_number: { main: "Mi lasci un numero WhatsApp? Ti mando lì la conferma.", short: "Un numero WhatsApp, per favore.", @@ -69,6 +79,7 @@ const PROMPTS = { afterCapture: "Ok, perfetto. Un attimo che ti riassumo tutto." }, + // STEP 8 — Riepilogo + Conferma step8_summary_confirm: { main: "Allora, ricapitoliamo un attimo. {{name}}, {{dateLabel}} alle {{time}}, per {{partySize}} persone. Va bene così?", short: "Riepilogo veloce: {{dateLabel}}, {{time}}, {{partySize}} persone. Confermi?", @@ -76,12 +87,17 @@ const PROMPTS = { confirmPrompt: "Se per te è tutto ok, confermo la prenotazione.", confirmShort: "Confermo?", hesitation: "Prenditi pure un secondo, non c’è fretta.", - outdoorWeather: "Ti segnalo solo una cosa: il tavolo è all’esterno e il meteo è un po’ incerto. Se cambia qualcosa, ci organizziamo senza problemi.", - kitchenNotActive: "A quell’orario la cucina non è attiva, però siamo aperti per bere qualcosa. Va bene lo stesso?", - promoNotValid: "Ti avviso solo che a quell’orario la promo non è attiva. Il resto resta invariato.", - tightAvailability: "È una disponibilità un po’ stretta, ma ci stiamo dentro. Se confermi, blocco subito." + outdoorWeather: + "Ti segnalo solo una cosa: il tavolo è all’esterno e il meteo è un po’ incerto. Se cambia qualcosa, ci organizziamo senza problemi.", + kitchenNotActive: + "A quell’orario la cucina non è attiva, però siamo aperti per bere qualcosa. Va bene lo stesso?", + promoNotValid: + "Ti avviso solo che a quell’orario la promo non è attiva. Il resto resta invariato.", + tightAvailability: + "È una disponibilità un po’ stretta, ma ci stiamo dentro. Se confermi, blocco subito." }, + // STEP 9A — Successo + WhatsApp step9_success: { main: "Perfetto, la prenotazione è confermata. Tra poco ricevi un messaggio WhatsApp con tutti i dettagli. Ti aspettiamo da TuttiBrilli.", short: "Fatto. Ti arriva subito la conferma su WhatsApp.", @@ -89,6 +105,7 @@ const PROMPTS = { goodbye: "A presto, buona serata." }, + // STEP 9B — Problemi => trasferimento operatore (SEMPRE) step9_fallback_transfer_operator: { main: "Un attimo solo, così ti seguiamo al meglio. Ti passo subito un collega.", short: "Ti metto in contatto con un collega.", From dd2f11face690a646dc9f18bfe47aa4b7ef3b757 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 08:59:55 +0100 Subject: [PATCH 038/143] Update app.js --- app.js | 72 ++++++++++++++++++++++++++++++++++------------------------ 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/app.js b/app.js index 29978fdeac..09992f7161 100644 --- a/app.js +++ b/app.js @@ -1,12 +1,27 @@ +/** + * app.js — TuttiBrilli Enoteca Voice Assistant + * Full version (A-Z) con: + * - dotenv + body-parser + * - sayIt() senza voice="alice" (usa voce Twilio Console) + * - fix telefono: non interrompe chiamata, prenotazione parziale su Calendar + * - parsing telefono robusto + default +39 + * - parsing date robusto + comandi "indietro/modifica/errore" + * - WhatsApp SOLO se Calendar OK e numero valido + */ + "use strict"; +require("dotenv").config(); + const express = require("express"); const twilio = require("twilio"); +const bodyParser = require("body-parser"); const { google } = require("googleapis"); + const { PROMPTS } = require("./prompts"); const app = express(); -app.use(express.urlencoded({ extended: false })); +app.use(bodyParser.urlencoded({ extended: false })); app.use(express.json()); // ======================= ENV ======================= @@ -71,14 +86,13 @@ const PREORDER_OPTIONS = [ { key: "apericena", label: "Apericena", priceEUR: null, constraints: {} }, { key: "dopocena", label: "Dopocena (dopo le 22:30)", priceEUR: null, constraints: { minTime: "22:30" } }, { key: "piatto_apericena", label: "Piatto Apericena", priceEUR: 25, constraints: {} }, - { - key: "piatto_apericena_promo", - label: "Piatto Apericena in promo (previa registrazione)", - priceEUR: null, - constraints: { promoOnly: true }, - }, + { key: "piatto_apericena_promo", label: "Piatto Apericena in promo (previa registrazione)", priceEUR: null, constraints: { promoOnly: true } }, ]; +function getPreorderOptionByKey(key) { + return PREORDER_OPTIONS.find((o) => o.key === key) || null; +} + // ======================= CONFIG: TABLES ======================= const TABLES = [ // INSIDE @@ -206,7 +220,6 @@ function goBack(session) { if (!session || typeof session.step !== "number") return; if (session.step <= 1) return; - // Mappa coerente col flusso: // 1 nome -> 2 data -> 3 ora -> 4 pax -> 5 note -> 6 preordine -> 8 area -> 10 telefono if (session.step === 2) session.step = 1; else if (session.step === 3) session.step = 2; @@ -254,12 +267,12 @@ function promptForStep(vr, session) { } /** - * ✅ DATE PARSER ENHANCED: - * - oggi, domani - * - tra/fra X giorni (anche in parole: "tre", "due"...) - * - 30/12, 30-12, 30 12 (+ optional year) + * DATE PARSER robusto: + * - oggi/domani + * - tra/fra X giorni (anche "tre") + * - 30/12, 30-12, 30 12 (+ anno opzionale) * - 2025-12-30 - * - 30 dicembre / il primo gennaio / trenta dicembre + * - 30 dicembre / trenta dicembre / primo gennaio * - questo venerdì / venerdì prossimo */ function parseItalianNumberToInt(text) { @@ -496,10 +509,6 @@ function parsePreorderChoiceKey(speech) { return "unknown"; } -function getPreorderOptionByKey(key) { - return PREORDER_OPTIONS.find((o) => o.key === key) || null; -} - // ======================= PHONE HELPERS ======================= function isValidPhoneE164(s) { return /^\+\d{8,15}$/.test(String(s || "").trim()); @@ -569,7 +578,7 @@ function speechToDigitsIT(raw) { function extractPhoneFromSpeech(speech) { if (!speech) return null; - // 1) prova direttamente dalle cifre + // 1) prova direttamente dalle cifre presenti let raw = String(speech).replace(/[^\d+]/g, ""); if (raw) { if (raw.startsWith("00")) raw = "+" + raw.slice(2); @@ -585,14 +594,14 @@ function extractPhoneFromSpeech(speech) { const e164 = "+" + digits; if (isValidPhoneE164(e164)) return e164; } else { - const e164 = "+39" + digits; + const e164 = "+39" + digits; // default IT if (isValidPhoneE164(e164)) return e164; } } } } - // 2) prova a convertire parole -> cifre + // 2) converti parole -> cifre const fromWords = speechToDigitsIT(speech); if (!fromWords) return null; @@ -698,8 +707,13 @@ function buildTwiml() { return new twilio.twiml.VoiceResponse(); } +/** + * VOICE FIX + * NON forziamo più "alice" + * Twilio userà la voce configurata in Console (es. Chirp3-HD-Aoede) + */ function sayIt(response, text) { - response.say({ voice: "alice", language: "it-IT" }, text); + response.say({ language: "it-IT" }, text); } function gatherSpeech(response, promptText) { @@ -894,6 +908,7 @@ async function createBookingEvent({ const { startLocal, endLocal } = computeStartEndLocal(dateISO, time24, durationMinutes); const privateKey = `callsid:${callSid || "no-callsid"}`; + // idempotenza const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -1117,10 +1132,7 @@ app.post("/voice", async (req, res) => { } if (bookingType === "operator") { - sayIt( - vr, - "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo." - ); + sayIt(vr, "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo."); } else if (bookingType === "drinks") { sayIt(vr, t("step4_confirm_time_ask_party_size.kitchenClosed.main")); } @@ -1183,7 +1195,7 @@ app.post("/voice", async (req, res) => { session.step = 6; gatherSpeech( vr, - "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, piatto apericena promo. Se non vuoi, dì nessuno." + "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); break; } @@ -1332,9 +1344,9 @@ app.post("/voice", async (req, res) => { } case 10: { - // ✅ Step telefono: NON deve mai bloccare Calendar. - // - Se il numero non viene capito: riprova massimo 2 volte. - // - Poi prosegui senza numero (prenotazione parziale su Calendar). + // ✅ FIX CRITICO: lo step telefono NON deve mai interrompere il flusso. + // Dopo 2 tentativi falliti, proseguiamo senza telefono/WA e salviamo comunque su Calendar (prenotazione parziale). + if (emptySpeech) { if (bumpRetries(session) <= 2) { gatherSpeech(vr, t("step7_whatsapp_number.error")); @@ -1459,7 +1471,7 @@ app.post("/voice", async (req, res) => { break; } - // ✅ WhatsApp SOLO se numero valido (e Calendar ok) + // ✅ WhatsApp SOLO se numero valido (e Calendar OK) if (session.autoConfirm && session.waTo && hasValidWaAddress(session.waTo)) { try { await sendWhatsAppConfirmation({ From a9e1a0198917b165583e35a9db39c7ccab9af71b Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 09:18:15 +0100 Subject: [PATCH 039/143] Refactor step comments for clarity in prompts.js --- prompts.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/prompts.js b/prompts.js index 3d5fae3aa4..fb8c7a8d1e 100644 --- a/prompts.js +++ b/prompts.js @@ -60,7 +60,7 @@ const PROMPTS = { "Ti dico la verità: per {{partySize}} persone a quell’orario siamo al completo. Se vuoi, proviamo un altro orario o un altro giorno." }, - // STEP 6 — Raccolta dettagli allergie/richieste (senza ripetere la domanda lunga) + // STEP 6 — Raccolta dettagli allergie/richieste step6_collect_notes: { main: "Dimmi pure cosa devo segnalare.", short: "Cosa devo segnare?", @@ -69,7 +69,7 @@ const PROMPTS = { noneClose: "Perfetto, tutto ok." }, - // STEP 7 — WhatsApp (qui, non prima) + // STEP 7 — WhatsApp step7_whatsapp_number: { main: "Mi lasci un numero WhatsApp? Ti mando lì la conferma.", short: "Un numero WhatsApp, per favore.", @@ -97,7 +97,7 @@ const PROMPTS = { "È una disponibilità un po’ stretta, ma ci stiamo dentro. Se confermi, blocco subito." }, - // STEP 9A — Successo + WhatsApp + // STEP 9A — Successo step9_success: { main: "Perfetto, la prenotazione è confermata. Tra poco ricevi un messaggio WhatsApp con tutti i dettagli. Ti aspettiamo da TuttiBrilli.", short: "Fatto. Ti arriva subito la conferma su WhatsApp.", @@ -105,7 +105,7 @@ const PROMPTS = { goodbye: "A presto, buona serata." }, - // STEP 9B — Problemi => trasferimento operatore (SEMPRE) + // STEP 9B — Fallback operatore step9_fallback_transfer_operator: { main: "Un attimo solo, così ti seguiamo al meglio. Ti passo subito un collega.", short: "Ti metto in contatto con un collega.", From 33c33f0db89003a7e9131814ca9efcc133a97439 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 28 Dec 2025 09:18:44 +0100 Subject: [PATCH 040/143] Refactor app.js for better structure and fixes Refactor app.js to improve clarity and functionality --- app.js | 418 +++++++++++++++++++++++---------------------------------- 1 file changed, 168 insertions(+), 250 deletions(-) diff --git a/app.js b/app.js index 09992f7161..fe07e65bbb 100644 --- a/app.js +++ b/app.js @@ -1,17 +1,18 @@ /** * app.js — TuttiBrilli Enoteca Voice Assistant - * Full version (A-Z) con: - * - dotenv + body-parser - * - sayIt() senza voice="alice" (usa voce Twilio Console) - * - fix telefono: non interrompe chiamata, prenotazione parziale su Calendar - * - parsing telefono robusto + default +39 - * - parsing date robusto + comandi "indietro/modifica/errore" - * - WhatsApp SOLO se Calendar OK e numero valido + * - dotenv opzionale (non rompe su Render se dotenv non installato) + * - sayIt() NON forza "alice" (usa voce configurata su Twilio Console) + * - Fix: chiamata non si interrompe al telefono + * - Fix: prenotazione parziale su Calendar se telefono non valido + * - Parsing date/telefono robusti + default +39 */ "use strict"; -require("dotenv").config(); +// dotenv opzionale: in produzione (Render) le env vars sono già presenti +try { + require("dotenv").config(); +} catch (_) {} const express = require("express"); const twilio = require("twilio"); @@ -25,7 +26,7 @@ app.use(bodyParser.urlencoded({ extended: false })); app.use(express.json()); // ======================= ENV ======================= -const PORT = process.env.PORT || 3001; +const PORT = process.env.PORT || 3000; const BASE_URL = process.env.BASE_URL || ""; const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; @@ -46,30 +47,7 @@ const HOLIDAYS_SET = new Set(HOLIDAYS_YYYY_MM_DD.split(",").map((s) => s.trim()) const twilioClient = TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) : null; -// ======================= PROMPTS HELPERS ======================= -function renderTemplate(str, vars = {}) { - const s = String(str || ""); - return s.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { - const v = vars[key]; - return v === undefined || v === null ? "" : String(v); - }); -} - -function pickPrompt(path, fallback = "") { - const parts = String(path || "").split("."); - let node = PROMPTS; - for (const p of parts) { - if (!node || typeof node !== "object" || !(p in node)) return fallback; - node = node[p]; - } - return typeof node === "string" ? node : fallback; -} - -function t(path, vars = {}, fallback = "") { - return renderTemplate(pickPrompt(path, fallback), vars); -} - -// ======================= CONFIG: OPENING HOURS ======================= +// ======================= OPENING HOURS ======================= const OPENING = { closedDay: 1, // Monday restaurant: { @@ -77,10 +55,10 @@ const OPENING = { friSat: { start: "18:30", end: "23:00" }, // Fri-Sat }, drinksOnly: { start: "18:30", end: "24:00" }, // everyday - musicNights: { days: [3, 5], from: "20:00" }, // Wed & Fri + musicNights: { days: [3, 5] }, // Wed & Fri }; -// ======================= CONFIG: PREORDER MENU ======================= +// ======================= PREORDER MENU ======================= const PREORDER_OPTIONS = [ { key: "cena", label: "Cena", priceEUR: null, constraints: {} }, { key: "apericena", label: "Apericena", priceEUR: null, constraints: {} }, @@ -93,7 +71,7 @@ function getPreorderOptionByKey(key) { return PREORDER_OPTIONS.find((o) => o.key === key) || null; } -// ======================= CONFIG: TABLES ======================= +// ======================= TABLES ======================= const TABLES = [ // INSIDE { id: "T1", area: "inside", min: 2, max: 4, notes: "più riservato" }, @@ -138,7 +116,73 @@ const TABLE_COMBINATIONS = [ { displayId: "T7F", area: "outside", replaces: ["T7F", "T8F"], min: 6, max: 8, notes: "unione T7F+T8F" }, ]; -// ======================= SESSIONS ======================= +// ======================= PROMPTS HELPERS ======================= +function renderTemplate(str, vars = {}) { + const s = String(str || ""); + return s.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { + const v = vars[key]; + return v === undefined || v === null ? "" : String(v); + }); +} + +function pickPrompt(path, fallback = "") { + const parts = String(path || "").split("."); + let node = PROMPTS; + for (const p of parts) { + if (!node || typeof node !== "object" || !(p in node)) return fallback; + node = node[p]; + } + return typeof node === "string" ? node : fallback; +} + +function t(path, vars = {}, fallback = "") { + return renderTemplate(pickPrompt(path, fallback), vars); +} + +// ======================= TWILIO HELPERS ======================= +function buildTwiml() { + return new twilio.twiml.VoiceResponse(); +} + +/** + * VOICE FIX: NON forziamo "alice" + * Twilio userà la voce configurata in Console (es. Chirp3-HD-Aoede) + */ +function sayIt(response, text) { + response.say({ language: "it-IT" }, text); +} + +function gatherSpeech(response, promptText) { + const actionUrl = `${BASE_URL}/voice`; + const gather = response.gather({ + input: "speech", + language: "it-IT", + speechTimeout: "auto", + action: actionUrl, + method: "POST", + }); + sayIt(gather, promptText); +} + +function isValidPhoneE164(s) { + return /^\+\d{8,15}$/.test(String(s || "").trim()); +} +function hasValidWaAddress(s) { + return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); +} + +function canForwardToHuman() { + return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && isValidPhoneE164(HUMAN_FORWARD_TO); +} + +function forwardToHumanTwiml() { + const vr = buildTwiml(); + sayIt(vr, t("step9_fallback_transfer_operator.main")); + vr.dial({}, HUMAN_FORWARD_TO); + return vr.toString(); +} + +// ======================= SESSION ======================= const sessions = new Map(); function getSession(callSid) { @@ -186,7 +230,7 @@ function resetRetries(session) { session.retries = 0; } -// ======================= PARSERS ======================= +// ======================= PARSING & UTIL ======================= function normalizeText(s) { return String(s || "").trim().toLowerCase().replace(/\s+/g, " "); } @@ -202,7 +246,7 @@ function nowLocal() { return new Date(); } -// ----------------------- BACK / EDIT COMMANDS ----------------------- +// ---- Back/edit commands function isBackCommand(speech) { const t = normalizeText(speech); return ( @@ -266,45 +310,15 @@ function promptForStep(vr, session) { } } -/** - * DATE PARSER robusto: - * - oggi/domani - * - tra/fra X giorni (anche "tre") - * - 30/12, 30-12, 30 12 (+ anno opzionale) - * - 2025-12-30 - * - 30 dicembre / trenta dicembre / primo gennaio - * - questo venerdì / venerdì prossimo - */ +// ---- Date parsing robusto function parseItalianNumberToInt(text) { - const t = normalizeText(text) - .replace(/[-,\.]/g, " ") - .replace(/\s+/g, " ") - .trim(); + const t = normalizeText(text).replace(/[-,\.]/g, " ").replace(/\s+/g, " ").trim(); const direct = { - uno: 1, - una: 1, - primo: 1, - due: 2, - tre: 3, - quattro: 4, - cinque: 5, - sei: 6, - sette: 7, - otto: 8, - nove: 9, - dieci: 10, - undici: 11, - dodici: 12, - tredici: 13, - quattordici: 14, - quindici: 15, - sedici: 16, - diciassette: 17, - diciotto: 18, - diciannove: 19, - venti: 20, - trenta: 30, + uno: 1, una: 1, primo: 1, + due: 2, tre: 3, quattro: 4, cinque: 5, sei: 6, sette: 7, otto: 8, nove: 9, + dieci: 10, undici: 11, dodici: 12, tredici: 13, quattordici: 14, quindici: 15, sedici: 16, diciassette: 17, + diciotto: 18, diciannove: 19, venti: 20, trenta: 30 }; if (direct[t] != null) return direct[t]; @@ -379,40 +393,26 @@ function parseDateIT(speech) { const weekdayMap = { domenica: 0, - lunedi: 1, - "lunedì": 1, - martedi: 2, - "martedì": 2, - mercoledi: 3, - "mercoledì": 3, - giovedi: 4, - "giovedì": 4, - venerdi: 5, - "venerdì": 5, + lunedi: 1, "lunedì": 1, + martedi: 2, "martedì": 2, + mercoledi: 3, "mercoledì": 3, + giovedi: 4, "giovedì": 4, + venerdi: 5, "venerdì": 5, sabato: 6, }; const hasQuesto = /\b(questo|questa|sto|sta)\b/.test(t); - const hasProssimo = - /\b(prossimo|prossima)\b/.test(t) || - /\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\s+prossim[oa]\b/.test(t); + const hasProssimo = /\b(prossimo|prossima)\b/.test(t); - const weekdayMatch = t.match( - /\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\b/ - ); + const weekdayMatch = t.match(/\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\b/); if (weekdayMatch) { const target = weekdayMap[weekdayMatch[1]]; const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const current = d.getDay(); - let diff = (target - current + 7) % 7; - if (hasProssimo) { - if (diff === 0) diff = 7; - else diff += 7; - } else if (hasQuesto) { - // ok - } else { + if (hasProssimo) diff = diff === 0 ? 7 : diff + 7; + else if (hasQuesto) { // ok } @@ -421,18 +421,8 @@ function parseDateIT(speech) { } const months = { - gennaio: 1, - febbraio: 2, - marzo: 3, - aprile: 4, - maggio: 5, - giugno: 6, - luglio: 7, - agosto: 8, - settembre: 9, - ottobre: 10, - novembre: 11, - dicembre: 12, + gennaio: 1, febbraio: 2, marzo: 3, aprile: 4, maggio: 5, giugno: 6, + luglio: 7, agosto: 8, settembre: 9, ottobre: 10, novembre: 11, dicembre: 12, }; const cleaned = t @@ -441,9 +431,7 @@ function parseDateIT(speech) { .replace(/\s+/g, " ") .trim(); - const m = cleaned.match( - /\b(.+?)\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/ - ); + const m = cleaned.match(/\b(.+?)\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); if (m) { const dd = parseItalianNumberToInt(m[1]); const mm = months[m[2]]; @@ -498,63 +486,28 @@ function parseAreaIT(speech) { function parsePreorderChoiceKey(speech) { const t = normalizeText(speech); - if (t.includes("promo")) return "piatto_apericena_promo"; if (t.includes("piatto") && t.includes("apericena")) return "piatto_apericena"; if (t.includes("dopocena") || t.includes("dopo cena") || t.includes("dopo")) return "dopocena"; if (t.includes("apericena")) return "apericena"; if (t.includes("cena")) return "cena"; if (t.includes("nessuno") || t.includes("nessuna") || t.includes("no")) return null; - return "unknown"; } -// ======================= PHONE HELPERS ======================= -function isValidPhoneE164(s) { - return /^\+\d{8,15}$/.test(String(s || "").trim()); -} -function hasValidWaAddress(s) { - return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); -} - +// ---- Phone parsing robusto + default +39 function speechToDigitsIT(raw) { const t = normalizeText(raw); + const map = { zero: "0", uno: "1", una: "1", due: "2", tre: "3", quattro: "4", cinque: "5", sei: "6", sette: "7", otto: "8", nove: "9" }; - const map = { - zero: "0", - uno: "1", - una: "1", - due: "2", - tre: "3", - quattro: "4", - cinque: "5", - sei: "6", - sette: "7", - otto: "8", - nove: "9", - }; - - const tokens = t - .replace(/[^a-z0-9+\s]/g, " ") - .replace(/\s+/g, " ") - .trim() - .split(" ") - .filter(Boolean); - + const tokens = t.replace(/[^a-z0-9+\s]/g, " ").replace(/\s+/g, " ").trim().split(" ").filter(Boolean); let out = ""; for (let i = 0; i < tokens.length; i++) { const w = tokens[i]; - if (/^\d+$/.test(w)) { - out += w; - continue; - } - - if (w === "+" || w === "piu" || w === "più") { - out += "+"; - continue; - } + if (/^\d+$/.test(w)) { out += w; continue; } + if (w === "+" || w === "piu" || w === "più") { out += "+"; continue; } if (w === "doppio" || w === "triplo") { const next = tokens[i + 1]; @@ -566,10 +519,7 @@ function speechToDigitsIT(raw) { } } - if (map[w]) { - out += map[w]; - continue; - } + if (map[w]) { out += map[w]; continue; } } return out; @@ -578,7 +528,7 @@ function speechToDigitsIT(raw) { function extractPhoneFromSpeech(speech) { if (!speech) return null; - // 1) prova direttamente dalle cifre presenti + // 1) cifre già presenti let raw = String(speech).replace(/[^\d+]/g, ""); if (raw) { if (raw.startsWith("00")) raw = "+" + raw.slice(2); @@ -594,14 +544,14 @@ function extractPhoneFromSpeech(speech) { const e164 = "+" + digits; if (isValidPhoneE164(e164)) return e164; } else { - const e164 = "+39" + digits; // default IT + const e164 = "+39" + digits; if (isValidPhoneE164(e164)) return e164; } } } } - // 2) converti parole -> cifre + // 2) parole -> cifre const fromWords = speechToDigitsIT(speech); if (!fromWords) return null; @@ -654,16 +604,16 @@ function deriveBookingTypeAndConfirm(dateISO, time24) { const d = new Date(`${dateISO}T00:00:00`); const day = d.getDay(); - if (day === OPENING.closedDay) return { bookingType: "operator", autoConfirm: false, reason: "Lunedì chiuso" }; + if (day === OPENING.closedDay) return { bookingType: "operator", autoConfirm: false }; const restWin = getRestaurantWindowForDay(day); const inRestaurant = isWithinWindow(time24, restWin.start, restWin.end); const inDrinks = isWithinWindow(time24, OPENING.drinksOnly.start, OPENING.drinksOnly.end); - if (inRestaurant) return { bookingType: "restaurant", autoConfirm: true, reason: null }; - if (inDrinks) return { bookingType: "drinks", autoConfirm: true, reason: "Fuori orario cucina" }; + if (inRestaurant) return { bookingType: "restaurant", autoConfirm: true }; + if (inDrinks) return { bookingType: "drinks", autoConfirm: true }; - return { bookingType: "closed", autoConfirm: false, reason: "Fuori orario" }; + return { bookingType: "closed", autoConfirm: false }; } function getDurationMinutes(people, dateObj) { @@ -679,6 +629,12 @@ function getDurationMinutes(people, dateObj) { return minutes; } +function toISODateWithOffset(dateISO, daysOffset) { + const d = new Date(`${dateISO}T00:00:00`); + d.setDate(d.getDate() + daysOffset); + return toISODate(d); +} + function computeStartEndLocal(dateISO, time24, durationMinutes) { const [sh, sm] = time24.split(":").map(Number); const startTotal = sh * 60 + sm; @@ -689,12 +645,7 @@ function computeStartEndLocal(dateISO, time24, durationMinutes) { const endH = Math.floor(endMinutesOfDay / 60); const endM = endMinutesOfDay % 60; - let endDateISO = dateISO; - if (endDayOffset > 0) { - const d = new Date(`${dateISO}T00:00:00`); - d.setDate(d.getDate() + endDayOffset); - endDateISO = toISODate(d); - } + const endDateISO = endDayOffset > 0 ? toISODateWithOffset(dateISO, endDayOffset) : dateISO; const startLocal = `${dateISO}T${String(sh).padStart(2, "0")}:${String(sm).padStart(2, "0")}:00`; const endLocal = `${endDateISO}T${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}:00`; @@ -702,44 +653,7 @@ function computeStartEndLocal(dateISO, time24, durationMinutes) { return { startLocal, endLocal }; } -// ======================= TWILIO HELPERS ======================= -function buildTwiml() { - return new twilio.twiml.VoiceResponse(); -} - -/** - * VOICE FIX - * NON forziamo più "alice" - * Twilio userà la voce configurata in Console (es. Chirp3-HD-Aoede) - */ -function sayIt(response, text) { - response.say({ language: "it-IT" }, text); -} - -function gatherSpeech(response, promptText) { - const actionUrl = `${BASE_URL}/voice`; - const gather = response.gather({ - input: "speech", - language: "it-IT", - speechTimeout: "auto", - action: actionUrl, - method: "POST", - }); - sayIt(gather, promptText); -} - -function canForwardToHuman() { - return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && isValidPhoneE164(HUMAN_FORWARD_TO); -} - -function forwardToHumanTwiml() { - const vr = buildTwiml(); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - vr.dial({}, HUMAN_FORWARD_TO); - return vr.toString(); -} - -// ======================= GOOGLE CALENDAR CLIENT ======================= +// ======================= GOOGLE CALENDAR ======================= function getServiceAccountJsonRaw() { if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { try { @@ -764,15 +678,10 @@ function getServiceAccountJsonRaw() { } function getCalendarClient() { - if (!GOOGLE_CALENDAR_ID) { - console.error("[CALENDAR] Missing GOOGLE_CALENDAR_ID"); - return null; - } + if (!GOOGLE_CALENDAR_ID) return null; + const raw = getServiceAccountJsonRaw(); - if (!raw) { - console.error("[CALENDAR] Missing service account JSON"); - return null; - } + if (!raw) return null; const creds = JSON.parse(raw); const scopes = ["https://www.googleapis.com/auth/calendar"]; @@ -780,7 +689,6 @@ function getCalendarClient() { return google.calendar({ version: "v3", auth }); } -// ======================= CALENDAR HELPERS ======================= function containsMarker(ev, markerLower) { const s = `${String(ev.summary || "")}\n${String(ev.description || "")}`.toLowerCase(); return s.includes(markerLower); @@ -908,7 +816,7 @@ async function createBookingEvent({ const { startLocal, endLocal } = computeStartEndLocal(dateISO, time24, durationMinutes); const privateKey = `callsid:${callSid || "no-callsid"}`; - // idempotenza + // Idempotenza su CallSid (salvata nel description) const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -919,7 +827,7 @@ async function createBookingEvent({ }); const found = (existing.data.items || []).find((ev) => String(ev.description || "").includes(privateKey)); - if (found) return { created: false, eventId: found.id, htmlLink: found.htmlLink }; + if (found) return { created: false, eventId: found.id }; const prefix = autoConfirm ? "" : "DA CONFERMARE • "; const summary = `${prefix}TB • ${tableDisplayId} • ${name} • ${people} pax`; @@ -961,7 +869,7 @@ async function createBookingEvent({ requestBody, }); - return { created: true, eventId: resp.data.id, htmlLink: resp.data.htmlLink }; + return { created: true, eventId: resp.data.id }; } // ======================= WHATSAPP CONFIRMATION ======================= @@ -1040,7 +948,7 @@ app.post("/voice", async (req, res) => { const emptySpeech = !normalizeText(speech); - // ✅ Comandi vocali: "indietro / modifica / errore" + // Back / modifica if (!emptySpeech && isBackCommand(speech)) { resetRetries(session); goBack(session); @@ -1132,8 +1040,14 @@ app.post("/voice", async (req, res) => { } if (bookingType === "operator") { - sayIt(vr, "Ti avviso che il lunedì siamo chiusi, ma possiamo aprire per eventi su conferma dell'operatore. Raccolgo i dati e ti ricontatteremo."); - } else if (bookingType === "drinks") { + // lunedì gestito da operatore + sayIt(vr, t("step9_fallback_transfer_operator.gentle")); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, t("step9_fallback_transfer_operator.main")); + break; + } + + if (bookingType === "drinks") { sayIt(vr, t("step4_confirm_time_ask_party_size.kitchenClosed.main")); } @@ -1148,7 +1062,7 @@ app.post("/voice", async (req, res) => { if (bumpRetries(session) > 2) { sayIt(vr, t("step5_party_size_ask_notes.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); break; } gatherSpeech(vr, t("step5_party_size_ask_notes.error")); @@ -1160,7 +1074,7 @@ app.post("/voice", async (req, res) => { if (bumpRetries(session) > 2) { sayIt(vr, t("step5_party_size_ask_notes.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); break; } gatherSpeech(vr, t("step5_party_size_ask_notes.error")); @@ -1182,7 +1096,7 @@ app.post("/voice", async (req, res) => { session.step = 6; gatherSpeech( vr, - "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." + "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); break; } @@ -1195,7 +1109,7 @@ app.post("/voice", async (req, res) => { session.step = 6; gatherSpeech( vr, - "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." + "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); break; } @@ -1244,15 +1158,15 @@ app.post("/voice", async (req, res) => { session.preorderLabel = null; resetRetries(session); session.step = 8; - gatherSpeech(vr, "Ok. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); + gatherSpeech(vr, "Perfetto. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); break; } if (opt.constraints && opt.constraints.minTime) { if (!isTimeAtOrAfter(session.time24, opt.constraints.minTime)) { - sayIt(vr, "Il dopocena è disponibile solo dopo le 22 e 30."); + sayIt(vr, t("step4_confirm_time_ask_party_size.afterDinner")); resetRetries(session); - gatherSpeech(vr, "Vuoi scegliere tra cena, apericena o piatto apericena? Oppure dì nessuno."); + gatherSpeech(vr, "Puoi scegliere: cena, apericena o piatto apericena. Oppure dì nessuno."); break; } } @@ -1291,6 +1205,7 @@ app.post("/voice", async (req, res) => { gatherSpeech(vr, t("step7_whatsapp_number.main")); break; } + if (area === "outside" || tt.includes("confermo") || tt.includes("va bene esterno")) { session.area = "outside"; session.pendingOutsideConfirm = false; @@ -1344,8 +1259,9 @@ app.post("/voice", async (req, res) => { } case 10: { - // ✅ FIX CRITICO: lo step telefono NON deve mai interrompere il flusso. - // Dopo 2 tentativi falliti, proseguiamo senza telefono/WA e salviamo comunque su Calendar (prenotazione parziale). + // ✅ FIX CRITICO: + // lo step telefono NON deve mai interrompere il flusso. + // Dopo 2 tentativi falliti, proseguiamo senza telefono/WA e salviamo comunque su Calendar. if (emptySpeech) { if (bumpRetries(session) <= 2) { @@ -1373,6 +1289,7 @@ app.post("/voice", async (req, res) => { } } + // durata const d = new Date(`${session.dateISO}T00:00:00`); session.durationMinutes = getDurationMinutes(session.people, d); @@ -1389,10 +1306,11 @@ app.post("/voice", async (req, res) => { if (!calendar) { sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.short")); break; } + // blocco “locale chiuso” + controllo “no promo” let isClosed = false; let hasNoPromo = false; @@ -1401,6 +1319,7 @@ app.post("/voice", async (req, res) => { hasNoPromo = await calendarHasMarkerOnDate(calendar, session.dateISO, "no promo"); } catch (err) { console.error("[CALENDAR] marker checks error:", err); + // se non riesco a controllare, tratto come "no promo" per sicurezza isClosed = false; hasNoPromo = true; } @@ -1408,7 +1327,7 @@ app.post("/voice", async (req, res) => { if (isClosed) { sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.short")); vr.hangup(); sessions.delete(callSid); break; @@ -1417,6 +1336,7 @@ app.post("/voice", async (req, res) => { const dayOk = isPromoEligibleByDay(session.dateISO); session.promoEligible = dayOk && !hasNoPromo; + // tavoli occupati let lockedSet; try { lockedSet = await getLockedTables(calendar, session.dateISO); @@ -1424,15 +1344,16 @@ app.post("/voice", async (req, res) => { console.error("[CALENDAR] getLockedTables error:", err); sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.short")); break; } + // assegna tavolo (priorità: combaciare pax) const chosen = allocateTable({ area: session.area, people: session.people, lockedSet }); if (!chosen) { sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi provare un altro orario. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.short")); break; } @@ -1440,7 +1361,7 @@ app.post("/voice", async (req, res) => { session.tableLocks = chosen.locks; session.tableNotes = chosen.notes || null; - // ✅ Calendar SEMPRE (anche prenotazione parziale) + // crea evento (sempre, anche senza telefono) try { await createBookingEvent({ callSid, @@ -1467,11 +1388,11 @@ app.post("/voice", async (req, res) => { console.error("[CALENDAR] create error:", e); sayIt(vr, t("step9_fallback_transfer_operator.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.short")); break; } - // ✅ WhatsApp SOLO se numero valido (e Calendar OK) + // WhatsApp solo se valido if (session.autoConfirm && session.waTo && hasValidWaAddress(session.waTo)) { try { await sendWhatsAppConfirmation({ @@ -1494,13 +1415,12 @@ app.post("/voice", async (req, res) => { } } + // finale voce if (session.waTo && hasValidWaAddress(session.waTo)) { sayIt(vr, t("step9_success.main")); } else { - sayIt( - vr, - "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi con calma il numero." - ); + // niente testo “whatsapp” dei prompts perché qui non abbiamo WhatsApp + sayIt(vr, "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi con calma il numero."); } sayIt(vr, t("step9_success.goodbye")); @@ -1520,13 +1440,12 @@ app.post("/voice", async (req, res) => { return res.type("text/xml").send(vr.toString()); } catch (err) { console.error("[VOICE] Error:", err); - sayIt(vr, "Mi dispiace, c'è stato un errore tecnico. Riprova tra poco."); + if (canForwardToHuman()) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - vr.dial({}, HUMAN_FORWARD_TO); - } else { - vr.hangup(); + return res.type("text/xml").send(forwardToHumanTwiml()); } + + sayIt(vr, t("step9_fallback_transfer_operator.main")); return res.type("text/xml").send(vr.toString()); } }); @@ -1536,6 +1455,5 @@ app.get("/", (req, res) => { }); app.listen(PORT, () => { - console.log(`[BOOT] Listening on port ${PORT}`); - console.log(`[BOOT] BASE_URL=${BASE_URL}`); + console.log(`Voice assistant running on port ${PORT}`); }); From b5f906c6834d47f8de4443ca4f7679598c132d97 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 29 Dec 2025 12:04:22 +0100 Subject: [PATCH 041/143] Update prompts.js --- prompts.js | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/prompts.js b/prompts.js index fb8c7a8d1e..f414c95201 100644 --- a/prompts.js +++ b/prompts.js @@ -5,21 +5,18 @@ */ const PROMPTS = { - // STEP 1 — Benvenuto + Nome step1_welcome_name: { main: "Buonasera, TuttiBrilli Enoteca. Ti do una mano con la prenotazione. Partiamo dal nome: come ti chiami?", short: "Buonasera, TuttiBrilli. Come ti chiami?", error: "Perdonami, è andato un po’ via l’audio. Mi ripeti il nome?" }, - // STEP 2 — Conferma nome + Data step2_confirm_name_ask_date: { main: "Perfetto, piacere {{name}}. Per che giorno pensavi?", short: "Perfetto {{name}}. Per che giorno?", error: "Voglio essere sicuro di aver capito: il nome è {{guess}}?" }, - // STEP 3 — Conferma data + Ora step3_confirm_date_ask_time: { main: "Ok, {{dateLabel}}. A che ora ti va di venire?", short: "Ok {{dateLabel}}. A che ora?", @@ -31,7 +28,6 @@ const PROMPTS = { todayVariant: "Perfetto, per questa sera. A che ora?" }, - // STEP 4 — Conferma ora + Persone step4_confirm_time_ask_party_size: { main: "Perfetto, alle {{time}}. In quanti sarete?", short: "Ok {{time}}. In quanti?", @@ -47,20 +43,15 @@ const PROMPTS = { afterDinner: "Perfetto, quindi dopocena. Quante persone sarete?" }, - // STEP 5 — Conferma persone + Allergie/Richieste step5_party_size_ask_notes: { main: "Perfetto, {{partySize}} persone. C’è qualche allergia, intolleranza o richiesta particolare?", short: "Ok {{partySize}}. Allergie o richieste?", error: "Scusami, non ho capito il numero. Me lo ripeti?", - largeGroupPositive: - "Ok, {{partySize}} persone. Ci organizziamo senza problemi. C’è qualche allergia o richiesta particolare?", - checkingAvailability: - "Un attimo solo che controllo la disponibilità per {{partySize}} persone. Ci metto pochissimo.", - noAvailability: - "Ti dico la verità: per {{partySize}} persone a quell’orario siamo al completo. Se vuoi, proviamo un altro orario o un altro giorno." + largeGroupPositive: "Ok, {{partySize}} persone. Ci organizziamo senza problemi. C’è qualche allergia o richiesta particolare?", + checkingAvailability: "Un attimo solo che controllo la disponibilità per {{partySize}} persone. Ci metto pochissimo.", + noAvailability: "Ti dico la verità: per {{partySize}} persone a quell’orario siamo al completo. Se vuoi, proviamo un altro orario o un altro giorno." }, - // STEP 6 — Raccolta dettagli allergie/richieste step6_collect_notes: { main: "Dimmi pure cosa devo segnalare.", short: "Cosa devo segnare?", @@ -69,7 +60,6 @@ const PROMPTS = { noneClose: "Perfetto, tutto ok." }, - // STEP 7 — WhatsApp step7_whatsapp_number: { main: "Mi lasci un numero WhatsApp? Ti mando lì la conferma.", short: "Un numero WhatsApp, per favore.", @@ -79,7 +69,6 @@ const PROMPTS = { afterCapture: "Ok, perfetto. Un attimo che ti riassumo tutto." }, - // STEP 8 — Riepilogo + Conferma step8_summary_confirm: { main: "Allora, ricapitoliamo un attimo. {{name}}, {{dateLabel}} alle {{time}}, per {{partySize}} persone. Va bene così?", short: "Riepilogo veloce: {{dateLabel}}, {{time}}, {{partySize}} persone. Confermi?", @@ -87,17 +76,12 @@ const PROMPTS = { confirmPrompt: "Se per te è tutto ok, confermo la prenotazione.", confirmShort: "Confermo?", hesitation: "Prenditi pure un secondo, non c’è fretta.", - outdoorWeather: - "Ti segnalo solo una cosa: il tavolo è all’esterno e il meteo è un po’ incerto. Se cambia qualcosa, ci organizziamo senza problemi.", - kitchenNotActive: - "A quell’orario la cucina non è attiva, però siamo aperti per bere qualcosa. Va bene lo stesso?", - promoNotValid: - "Ti avviso solo che a quell’orario la promo non è attiva. Il resto resta invariato.", - tightAvailability: - "È una disponibilità un po’ stretta, ma ci stiamo dentro. Se confermi, blocco subito." + outdoorWeather: "Ti segnalo solo una cosa: il tavolo è all’esterno e il meteo è un po’ incerto. Se cambia qualcosa, ci organizziamo senza problemi.", + kitchenNotActive: "A quell’orario la cucina non è attiva, però siamo aperti per bere qualcosa. Va bene lo stesso?", + promoNotValid: "Ti avviso solo che a quell’orario la promo non è attiva. Il resto resta invariato.", + tightAvailability: "È una disponibilità un po’ stretta, ma ci stiamo dentro. Se confermi, blocco subito." }, - // STEP 9A — Successo step9_success: { main: "Perfetto, la prenotazione è confermata. Tra poco ricevi un messaggio WhatsApp con tutti i dettagli. Ti aspettiamo da TuttiBrilli.", short: "Fatto. Ti arriva subito la conferma su WhatsApp.", @@ -105,7 +89,6 @@ const PROMPTS = { goodbye: "A presto, buona serata." }, - // STEP 9B — Fallback operatore step9_fallback_transfer_operator: { main: "Un attimo solo, così ti seguiamo al meglio. Ti passo subito un collega.", short: "Ti metto in contatto con un collega.", From 01439307055a85f38c4c8200e47e22af742e2266 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 29 Dec 2025 12:04:50 +0100 Subject: [PATCH 042/143] Update app.js --- app.js | 507 +++++++++++++++++++++++++++------------------------------ 1 file changed, 238 insertions(+), 269 deletions(-) diff --git a/app.js b/app.js index fe07e65bbb..0750ffa1dc 100644 --- a/app.js +++ b/app.js @@ -1,15 +1,14 @@ /** * app.js — TuttiBrilli Enoteca Voice Assistant - * - dotenv opzionale (non rompe su Render se dotenv non installato) - * - sayIt() NON forza "alice" (usa voce configurata su Twilio Console) - * - Fix: chiamata non si interrompe al telefono - * - Fix: prenotazione parziale su Calendar se telefono non valido - * - Parsing date/telefono robusti + default +39 + * FIX CRITICO: la chiamata non cade allo step telefono + * - /voice gestisce SOLO dialogo/gather (risposte veloci) + * - /finalize esegue Calendar + WhatsApp (operazioni pesanti) via redirect Twilio + * - dotenv opzionale (no crash su Render) */ "use strict"; -// dotenv opzionale: in produzione (Render) le env vars sono già presenti +// dotenv opzionale: su Render non serve try { require("dotenv").config(); } catch (_) {} @@ -27,7 +26,7 @@ app.use(express.json()); // ======================= ENV ======================= const PORT = process.env.PORT || 3000; -const BASE_URL = process.env.BASE_URL || ""; +const BASE_URL = process.env.BASE_URL || ""; // su Render metti https://.onrender.com const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; @@ -145,15 +144,14 @@ function buildTwiml() { } /** - * VOICE FIX: NON forziamo "alice" - * Twilio userà la voce configurata in Console (es. Chirp3-HD-Aoede) + * NON forziamo "alice": usa voce configurata su Twilio Console */ function sayIt(response, text) { response.say({ language: "it-IT" }, text); } function gatherSpeech(response, promptText) { - const actionUrl = `${BASE_URL}/voice`; + const actionUrl = BASE_URL ? `${BASE_URL}/voice` : "/voice"; const gather = response.gather({ input: "speech", language: "it-IT", @@ -230,7 +228,7 @@ function resetRetries(session) { session.retries = 0; } -// ======================= PARSING & UTIL ======================= +// ======================= UTIL/PARSING ======================= function normalizeText(s) { return String(s || "").trim().toLowerCase().replace(/\s+/g, " "); } @@ -242,10 +240,6 @@ function toISODate(d) { return `${y}-${m}-${day}`; } -function nowLocal() { - return new Date(); -} - // ---- Back/edit commands function isBackCommand(speech) { const t = normalizeText(speech); @@ -295,7 +289,7 @@ function promptForStep(vr, session) { case 6: gatherSpeech( vr, - "Vuoi preordinare qualcosa? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." + "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); return; case 8: @@ -310,7 +304,7 @@ function promptForStep(vr, session) { } } -// ---- Date parsing robusto +// ---- Date parsing robusto (come prima, completo) function parseItalianNumberToInt(text) { const t = normalizeText(text).replace(/[-,\.]/g, " ").replace(/\s+/g, " ").trim(); @@ -346,7 +340,7 @@ function parseItalianNumberToInt(text) { function parseDateIT(speech) { const t0 = normalizeText(speech); const t = t0.replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); - const today = nowLocal(); + const today = new Date(); if (t.includes("oggi")) return toISODate(today); if (t.includes("domani")) { @@ -409,8 +403,8 @@ function parseDateIT(speech) { const target = weekdayMap[weekdayMatch[1]]; const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const current = d.getDay(); - let diff = (target - current + 7) % 7; + let diff = (target - current + 7) % 7; if (hasProssimo) diff = diff === 0 ? 7 : diff + 7; else if (hasQuesto) { // ok @@ -495,7 +489,7 @@ function parsePreorderChoiceKey(speech) { return "unknown"; } -// ---- Phone parsing robusto + default +39 +// ---- Phone parsing robusto + default +39 (come prima) function speechToDigitsIT(raw) { const t = normalizeText(raw); const map = { zero: "0", uno: "1", una: "1", due: "2", tre: "3", quattro: "4", cinque: "5", sei: "6", sette: "7", otto: "8", nove: "9" }; @@ -528,7 +522,6 @@ function speechToDigitsIT(raw) { function extractPhoneFromSpeech(speech) { if (!speech) return null; - // 1) cifre già presenti let raw = String(speech).replace(/[^\d+]/g, ""); if (raw) { if (raw.startsWith("00")) raw = "+" + raw.slice(2); @@ -551,7 +544,6 @@ function extractPhoneFromSpeech(speech) { } } - // 2) parole -> cifre const fromWords = speechToDigitsIT(speech); if (!fromWords) return null; @@ -629,12 +621,6 @@ function getDurationMinutes(people, dateObj) { return minutes; } -function toISODateWithOffset(dateISO, daysOffset) { - const d = new Date(`${dateISO}T00:00:00`); - d.setDate(d.getDate() + daysOffset); - return toISODate(d); -} - function computeStartEndLocal(dateISO, time24, durationMinutes) { const [sh, sm] = time24.split(":").map(Number); const startTotal = sh * 60 + sm; @@ -645,7 +631,12 @@ function computeStartEndLocal(dateISO, time24, durationMinutes) { const endH = Math.floor(endMinutesOfDay / 60); const endM = endMinutesOfDay % 60; - const endDateISO = endDayOffset > 0 ? toISODateWithOffset(dateISO, endDayOffset) : dateISO; + let endDateISO = dateISO; + if (endDayOffset > 0) { + const d = new Date(`${dateISO}T00:00:00`); + d.setDate(d.getDate() + endDayOffset); + endDateISO = toISODate(d); + } const startLocal = `${dateISO}T${String(sh).padStart(2, "0")}:${String(sm).padStart(2, "0")}:00`; const endLocal = `${endDateISO}T${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}:00`; @@ -694,29 +685,6 @@ function containsMarker(ev, markerLower) { return s.includes(markerLower); } -async function calendarHasMarkerOnDate(calendar, dateISO, markerLower) { - const timeMin = `${dateISO}T00:00:00Z`; - const timeMax = `${dateISO}T23:59:59Z`; - const resp = await calendar.events.list({ - calendarId: GOOGLE_CALENDAR_ID, - timeMin, - timeMax, - singleEvents: true, - maxResults: 250, - orderBy: "startTime", - }); - return (resp.data.items || []).some((ev) => containsMarker(ev, markerLower)); -} - -function isPromoEligibleByDay(dateISO) { - const d = new Date(`${dateISO}T00:00:00`); - const day = d.getDay(); - const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; // Tue-Sun excl Fri - if (!allowedDay) return false; - if (HOLIDAYS_SET.has(dateISO)) return false; - return true; -} - function parseLocksFromEvent(ev) { const d = String(ev.description || ""); const m = d.match(/LOCKS:\s*([A-Z0-9,]+)/i); @@ -724,7 +692,13 @@ function parseLocksFromEvent(ev) { return m[1].split(",").map((s) => s.trim()).filter(Boolean); } -async function getLockedTables(calendar, dateISO) { +/** + * ✅ UNA SOLA CHIAMATA Calendar per: + * - locale chiuso + * - no promo + * - locks tavoli + */ +async function getDayCalendarSnapshot(calendar, dateISO) { const timeMin = `${dateISO}T00:00:00Z`; const timeMax = `${dateISO}T23:59:59Z`; @@ -737,11 +711,27 @@ async function getLockedTables(calendar, dateISO) { maxResults: 250, }); + const items = resp.data.items || []; const locked = new Set(); - for (const ev of resp.data.items || []) { + let isClosed = false; + let hasNoPromo = false; + + for (const ev of items) { + if (containsMarker(ev, "locale chiuso")) isClosed = true; + if (containsMarker(ev, "no promo")) hasNoPromo = true; for (const tId of parseLocksFromEvent(ev)) locked.add(tId); } - return locked; + + return { isClosed, hasNoPromo, lockedSet: locked }; +} + +function isPromoEligibleByDay(dateISO) { + const d = new Date(`${dateISO}T00:00:00`); + const day = d.getDay(); + const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; // Tue-Sun excl Fri + if (!allowedDay) return false; + if (HOLIDAYS_SET.has(dateISO)) return false; + return true; } // ======================= TABLE ALLOCATION ======================= @@ -788,35 +778,33 @@ function allocateTable({ area, people, lockedSet }) { } // ======================= EVENT CREATION (IDEMPOTENT) ======================= -async function createBookingEvent({ - callSid, - name, - dateISO, - time24, - people, - phone, - waTo, - area, - bookingType, - autoConfirm, - durationMinutes, - tableDisplayId, - tableLocks, - tableNotes, - specialRequestsRaw, - preorderLabel, - preorderPriceText, - outsideDisclaimer, - promoEligible, -}) { - const calendar = getCalendarClient(); - if (!calendar) throw new Error("Calendar client not configured"); +async function createBookingEvent(calendar, payload) { + const { + callSid, + name, + dateISO, + time24, + people, + phone, + waTo, + area, + bookingType, + autoConfirm, + durationMinutes, + tableDisplayId, + tableLocks, + tableNotes, + specialRequestsRaw, + preorderLabel, + preorderPriceText, + outsideDisclaimer, + promoEligible, + } = payload; const tz = GOOGLE_CALENDAR_TZ; const { startLocal, endLocal } = computeStartEndLocal(dateISO, time24, durationMinutes); const privateKey = `callsid:${callSid || "no-callsid"}`; - // Idempotenza su CallSid (salvata nel description) const existing = await calendar.events.list({ calendarId: GOOGLE_CALENDAR_ID, q: privateKey, @@ -872,25 +860,27 @@ async function createBookingEvent({ return { created: true, eventId: resp.data.id }; } -// ======================= WHATSAPP CONFIRMATION ======================= -async function sendWhatsAppConfirmation({ - waTo, - name, - dateISO, - time24, - people, - tableDisplayId, - area, - specialRequestsRaw, - preorderLabel, - preorderPriceText, - outsideDisclaimer, - bookingType, - promoEligible, -}) { - if (!twilioClient) throw new Error("Twilio client not configured"); - if (!TWILIO_WHATSAPP_FROM) throw new Error("Missing TWILIO_WHATSAPP_FROM"); - if (!waTo || !hasValidWaAddress(waTo)) throw new Error("Invalid WhatsApp address"); +// ======================= WHATSAPP (fire-and-forget) ======================= +async function sendWhatsAppConfirmation(payload) { + const { + waTo, + name, + dateISO, + time24, + people, + tableDisplayId, + area, + specialRequestsRaw, + preorderLabel, + preorderPriceText, + outsideDisclaimer, + bookingType, + promoEligible, + } = payload; + + if (!twilioClient) return; + if (!TWILIO_WHATSAPP_FROM) return; + if (!waTo || !hasValidWaAddress(waTo)) return; const lines = [ `Ciao ${name}! Prenotazione registrata ✅`, @@ -921,23 +911,24 @@ async function sendWhatsAppConfirmation({ const body = lines.join("\n"); - const msg = await twilioClient.messages.create({ + await twilioClient.messages.create({ from: TWILIO_WHATSAPP_FROM, to: waTo, body, }); - - return msg.sid; } // ======================= ROUTES ======================= app.get("/health", (req, res) => res.json({ ok: true })); +/** + * /voice: SOLO dialogo (risposte rapide). + * IMPORTANTISSIMO: allo step 10 dopo aver salvato il numero facciamo redirect a /finalize. + */ app.post("/voice", async (req, res) => { const callSid = req.body.CallSid || ""; const speech = req.body.SpeechResult || ""; const session = getSession(callSid); - const vr = buildTwiml(); try { @@ -948,7 +939,7 @@ app.post("/voice", async (req, res) => { const emptySpeech = !normalizeText(speech); - // Back / modifica + // comandi indietro/modifica if (!emptySpeech && isBackCommand(speech)) { resetRetries(session); goBack(session); @@ -974,9 +965,8 @@ app.post("/voice", async (req, res) => { case 2: { if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, t("step3_confirm_date_ask_time.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); break; } gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); @@ -986,9 +976,8 @@ app.post("/voice", async (req, res) => { const dateISO = parseDateIT(speech); if (!dateISO) { if (bumpRetries(session) > 2) { - sayIt(vr, t("step3_confirm_date_ask_time.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); break; } gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); @@ -1005,9 +994,8 @@ app.post("/voice", async (req, res) => { case 3: { if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, t("step4_confirm_time_ask_party_size.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); break; } gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); @@ -1017,9 +1005,8 @@ app.post("/voice", async (req, res) => { const time24 = parseTimeIT(speech); if (!time24) { if (bumpRetries(session) > 2) { - sayIt(vr, t("step4_confirm_time_ask_party_size.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare più tardi. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); break; } gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); @@ -1035,13 +1022,12 @@ app.post("/voice", async (req, res) => { if (bookingType === "closed") { sayIt(vr, t("step4_confirm_time_ask_party_size.outsideHours.main")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, "Puoi richiamare durante l'orario di apertura. Grazie."); + sayIt(vr, t("step9_fallback_transfer_operator.main")); break; } if (bookingType === "operator") { - // lunedì gestito da operatore - sayIt(vr, t("step9_fallback_transfer_operator.gentle")); + // lunedì: operatore if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, t("step9_fallback_transfer_operator.main")); break; @@ -1060,7 +1046,6 @@ app.post("/voice", async (req, res) => { case 4: { if (emptySpeech) { if (bumpRetries(session) > 2) { - sayIt(vr, t("step5_party_size_ask_notes.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, t("step9_fallback_transfer_operator.main")); break; @@ -1072,7 +1057,6 @@ app.post("/voice", async (req, res) => { const people = parsePeopleIT(speech); if (!people || people < 1 || people > 18) { if (bumpRetries(session) > 2) { - sayIt(vr, t("step5_party_size_ask_notes.error")); if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, t("step9_fallback_transfer_operator.main")); break; @@ -1153,16 +1137,7 @@ app.post("/voice", async (req, res) => { } const opt = getPreorderOptionByKey(key); - if (!opt) { - session.preorderChoiceKey = null; - session.preorderLabel = null; - resetRetries(session); - session.step = 8; - gatherSpeech(vr, "Perfetto. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - break; - } - - if (opt.constraints && opt.constraints.minTime) { + if (opt && opt.constraints && opt.constraints.minTime) { if (!isTimeAtOrAfter(session.time24, opt.constraints.minTime)) { sayIt(vr, t("step4_confirm_time_ask_party_size.afterDinner")); resetRetries(session); @@ -1171,8 +1146,8 @@ app.post("/voice", async (req, res) => { } } - session.preorderChoiceKey = opt.key; - session.preorderLabel = opt.label; + session.preorderChoiceKey = opt ? opt.key : null; + session.preorderLabel = opt ? opt.label : null; resetRetries(session); session.step = 8; @@ -1259,10 +1234,7 @@ app.post("/voice", async (req, res) => { } case 10: { - // ✅ FIX CRITICO: - // lo step telefono NON deve mai interrompere il flusso. - // Dopo 2 tentativi falliti, proseguiamo senza telefono/WA e salviamo comunque su Calendar. - + // STEP TELEFONO: qui NON facciamo Calendar. Salviamo e REDIRECT a /finalize. if (emptySpeech) { if (bumpRetries(session) <= 2) { gatherSpeech(vr, t("step7_whatsapp_number.error")); @@ -1289,143 +1261,12 @@ app.post("/voice", async (req, res) => { } } - // durata - const d = new Date(`${session.dateISO}T00:00:00`); - session.durationMinutes = getDurationMinutes(session.people, d); - - const outsideDisclaimer = - session.area === "outside" ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." : null; + // ✅ risposta veloce + redirect (evita timeout Twilio) + sayIt(vr, t("step7_whatsapp_number.afterCapture")); - let preorderPriceText = null; - if (session.preorderChoiceKey) { - const opt = getPreorderOptionByKey(session.preorderChoiceKey); - if (opt && typeof opt.priceEUR === "number") preorderPriceText = `${opt.priceEUR} €`; - } - - const calendar = getCalendarClient(); - if (!calendar) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.short")); - break; - } - - // blocco “locale chiuso” + controllo “no promo” - let isClosed = false; - let hasNoPromo = false; - - try { - isClosed = await calendarHasMarkerOnDate(calendar, session.dateISO, "locale chiuso"); - hasNoPromo = await calendarHasMarkerOnDate(calendar, session.dateISO, "no promo"); - } catch (err) { - console.error("[CALENDAR] marker checks error:", err); - // se non riesco a controllare, tratto come "no promo" per sicurezza - isClosed = false; - hasNoPromo = true; - } + const finalizeUrl = BASE_URL ? `${BASE_URL}/finalize` : "/finalize"; + vr.redirect({ method: "POST" }, finalizeUrl); - if (isClosed) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.short")); - vr.hangup(); - sessions.delete(callSid); - break; - } - - const dayOk = isPromoEligibleByDay(session.dateISO); - session.promoEligible = dayOk && !hasNoPromo; - - // tavoli occupati - let lockedSet; - try { - lockedSet = await getLockedTables(calendar, session.dateISO); - } catch (err) { - console.error("[CALENDAR] getLockedTables error:", err); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.short")); - break; - } - - // assegna tavolo (priorità: combaciare pax) - const chosen = allocateTable({ area: session.area, people: session.people, lockedSet }); - if (!chosen) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.short")); - break; - } - - session.tableDisplayId = chosen.displayId; - session.tableLocks = chosen.locks; - session.tableNotes = chosen.notes || null; - - // crea evento (sempre, anche senza telefono) - try { - await createBookingEvent({ - callSid, - name: session.name, - dateISO: session.dateISO, - time24: session.time24, - people: session.people, - phone: session.phone, - waTo: session.waTo, - area: session.area, - bookingType: session.bookingType, - autoConfirm: session.autoConfirm, - durationMinutes: session.durationMinutes, - tableDisplayId: session.tableDisplayId, - tableLocks: session.tableLocks, - tableNotes: session.tableNotes, - specialRequestsRaw: session.specialRequestsRaw, - preorderLabel: session.preorderLabel, - preorderPriceText, - outsideDisclaimer, - promoEligible: Boolean(session.promoEligible), - }); - } catch (e) { - console.error("[CALENDAR] create error:", e); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.short")); - break; - } - - // WhatsApp solo se valido - if (session.autoConfirm && session.waTo && hasValidWaAddress(session.waTo)) { - try { - await sendWhatsAppConfirmation({ - waTo: session.waTo, - name: session.name, - dateISO: session.dateISO, - time24: session.time24, - people: session.people, - tableDisplayId: session.tableDisplayId, - area: session.area, - specialRequestsRaw: session.specialRequestsRaw, - preorderLabel: session.preorderLabel, - preorderPriceText, - outsideDisclaimer, - bookingType: session.bookingType, - promoEligible: Boolean(session.promoEligible), - }); - } catch (e) { - console.error("[WHATSAPP] send error:", e); - } - } - - // finale voce - if (session.waTo && hasValidWaAddress(session.waTo)) { - sayIt(vr, t("step9_success.main")); - } else { - // niente testo “whatsapp” dei prompts perché qui non abbiamo WhatsApp - sayIt(vr, "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi con calma il numero."); - } - sayIt(vr, t("step9_success.goodbye")); - - vr.hangup(); - sessions.delete(callSid); break; } @@ -1440,12 +1281,140 @@ app.post("/voice", async (req, res) => { return res.type("text/xml").send(vr.toString()); } catch (err) { console.error("[VOICE] Error:", err); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); + sayIt(vr, t("step9_fallback_transfer_operator.main")); + return res.type("text/xml").send(vr.toString()); + } +}); - if (canForwardToHuman()) { - return res.type("text/xml").send(forwardToHumanTwiml()); +/** + * /finalize: qui facciamo Calendar + WhatsApp. + * Twilio ci arriva con redirect dopo lo step telefono. + */ +app.post("/finalize", async (req, res) => { + const callSid = req.body.CallSid || ""; + const session = getSession(callSid); + const vr = buildTwiml(); + + try { + if (!session || !session.name || !session.dateISO || !session.time24 || !session.people || !session.area) { + sayIt(vr, t("step9_fallback_transfer_operator.main")); + if (canForwardToHuman()) { + vr.dial({}, HUMAN_FORWARD_TO); + } else { + vr.hangup(); + } + return res.type("text/xml").send(vr.toString()); + } + + const calendar = getCalendarClient(); + if (!calendar) { + sayIt(vr, t("step9_fallback_transfer_operator.main")); + if (canForwardToHuman()) vr.dial({}, HUMAN_FORWARD_TO); + else vr.hangup(); + return res.type("text/xml").send(vr.toString()); + } + + // durata + const d = new Date(`${session.dateISO}T00:00:00`); + session.durationMinutes = getDurationMinutes(session.people, d); + + const outsideDisclaimer = + session.area === "outside" ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." : null; + + let preorderPriceText = null; + if (session.preorderChoiceKey) { + const opt = getPreorderOptionByKey(session.preorderChoiceKey); + if (opt && typeof opt.priceEUR === "number") preorderPriceText = `${opt.priceEUR} €`; } + // ✅ UNA SOLA list per chiusura/no promo/locks + const snap = await getDayCalendarSnapshot(calendar, session.dateISO); + + if (snap.isClosed) { + sayIt(vr, t("step9_fallback_transfer_operator.main")); + if (canForwardToHuman()) vr.dial({}, HUMAN_FORWARD_TO); + else vr.hangup(); + sessions.delete(callSid); + return res.type("text/xml").send(vr.toString()); + } + + const dayOk = isPromoEligibleByDay(session.dateISO); + session.promoEligible = dayOk && !snap.hasNoPromo; + + // assegna tavolo + const chosen = allocateTable({ area: session.area, people: session.people, lockedSet: snap.lockedSet }); + if (!chosen) { + sayIt(vr, t("step9_fallback_transfer_operator.main")); + if (canForwardToHuman()) vr.dial({}, HUMAN_FORWARD_TO); + else vr.hangup(); + sessions.delete(callSid); + return res.type("text/xml").send(vr.toString()); + } + + session.tableDisplayId = chosen.displayId; + session.tableLocks = chosen.locks; + session.tableNotes = chosen.notes || null; + + // crea evento (sempre, anche senza telefono) + await createBookingEvent(calendar, { + callSid, + name: session.name, + dateISO: session.dateISO, + time24: session.time24, + people: session.people, + phone: session.phone, + waTo: session.waTo, + area: session.area, + bookingType: session.bookingType, + autoConfirm: session.autoConfirm, + durationMinutes: session.durationMinutes, + tableDisplayId: session.tableDisplayId, + tableLocks: session.tableLocks, + tableNotes: session.tableNotes, + specialRequestsRaw: session.specialRequestsRaw, + preorderLabel: session.preorderLabel, + preorderPriceText, + outsideDisclaimer, + promoEligible: Boolean(session.promoEligible), + }); + + // WhatsApp solo se numero valido (fire-and-forget) + if (session.autoConfirm && session.waTo && hasValidWaAddress(session.waTo)) { + sendWhatsAppConfirmation({ + waTo: session.waTo, + name: session.name, + dateISO: session.dateISO, + time24: session.time24, + people: session.people, + tableDisplayId: session.tableDisplayId, + area: session.area, + specialRequestsRaw: session.specialRequestsRaw, + preorderLabel: session.preorderLabel, + preorderPriceText, + outsideDisclaimer, + bookingType: session.bookingType, + promoEligible: Boolean(session.promoEligible), + }).catch((e) => console.error("[WHATSAPP] send error:", e)); + } + + // voce finale + if (session.waTo && hasValidWaAddress(session.waTo)) { + sayIt(vr, t("step9_success.main")); + } else { + sayIt(vr, "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi con calma il numero."); + } + sayIt(vr, t("step9_success.goodbye")); + + vr.hangup(); + sessions.delete(callSid); + return res.type("text/xml").send(vr.toString()); + } catch (err) { + console.error("[FINALIZE] error:", err); + if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, t("step9_fallback_transfer_operator.main")); + vr.hangup(); + sessions.delete(callSid); return res.type("text/xml").send(vr.toString()); } }); From c97ca9ebff6cdb9618345554b9341c3098fd7c2d Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 29 Dec 2025 12:39:01 +0100 Subject: [PATCH 043/143] Update prompts.js From 3c076e66b2a4fd708296456e44216d9d0296c127 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 29 Dec 2025 12:39:30 +0100 Subject: [PATCH 044/143] Update app.js --- app.js | 1128 +++++--------------------------------------------------- 1 file changed, 89 insertions(+), 1039 deletions(-) diff --git a/app.js b/app.js index 0750ffa1dc..de57f873ca 100644 --- a/app.js +++ b/app.js @@ -1,14 +1,6 @@ -/** - * app.js — TuttiBrilli Enoteca Voice Assistant - * FIX CRITICO: la chiamata non cade allo step telefono - * - /voice gestisce SOLO dialogo/gather (risposte veloci) - * - /finalize esegue Calendar + WhatsApp (operazioni pesanti) via redirect Twilio - * - dotenv opzionale (no crash su Render) - */ - "use strict"; -// dotenv opzionale: su Render non serve +// dotenv opzionale (su Render non serve) try { require("dotenv").config(); } catch (_) {} @@ -26,7 +18,7 @@ app.use(express.json()); // ======================= ENV ======================= const PORT = process.env.PORT || 3000; -const BASE_URL = process.env.BASE_URL || ""; // su Render metti https://.onrender.com +const BASE_URL = process.env.BASE_URL || ""; const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; @@ -50,10 +42,10 @@ const twilioClient = const OPENING = { closedDay: 1, // Monday restaurant: { - default: { start: "18:30", end: "22:30" }, // Tue-Thu, Sun - friSat: { start: "18:30", end: "23:00" }, // Fri-Sat + default: { start: "18:30", end: "22:30" }, + friSat: { start: "18:30", end: "23:00" }, }, - drinksOnly: { start: "18:30", end: "24:00" }, // everyday + drinksOnly: { start: "18:30", end: "24:00" }, musicNights: { days: [3, 5] }, // Wed & Fri }; @@ -72,7 +64,6 @@ function getPreorderOptionByKey(key) { // ======================= TABLES ======================= const TABLES = [ - // INSIDE { id: "T1", area: "inside", min: 2, max: 4, notes: "più riservato" }, { id: "T2", area: "inside", min: 2, max: 4, notes: "più riservato" }, { id: "T3", area: "inside", min: 2, max: 4, notes: "più riservato" }, @@ -91,7 +82,6 @@ const TABLES = [ { id: "T16", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, { id: "T17", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, - // OUTSIDE { id: "T1F", area: "outside", min: 2, max: 2, notes: "botte con sgabelli" }, { id: "T2F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, { id: "T3F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, @@ -102,7 +92,6 @@ const TABLES = [ ]; const TABLE_COMBINATIONS = [ - // INSIDE unions { displayId: "T1", area: "inside", replaces: ["T1", "T2"], min: 6, max: 6, notes: "unione T1+T2" }, { displayId: "T3", area: "inside", replaces: ["T3", "T4"], min: 6, max: 6, notes: "unione T3+T4" }, { displayId: "T14", area: "inside", replaces: ["T14", "T15"], min: 8, max: 18, notes: "unione T14+T15" }, @@ -110,11 +99,19 @@ const TABLE_COMBINATIONS = [ { displayId: "T12", area: "inside", replaces: ["T12", "T13"], min: 6, max: 6, notes: "unione T12+T13" }, { displayId: "T11", area: "inside", replaces: ["T11", "T12", "T13"], min: 8, max: 10, notes: "unione T11+T12+T13" }, { displayId: "T16", area: "inside", replaces: ["T16", "T17"], min: 8, max: 10, notes: "unione T16+T17" }, - - // OUTSIDE union { displayId: "T7F", area: "outside", replaces: ["T7F", "T8F"], min: 6, max: 8, notes: "unione T7F+T8F" }, ]; +// ======================= XML SAFE TEXT ======================= +function xmlEscape(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + // ======================= PROMPTS HELPERS ======================= function renderTemplate(str, vars = {}) { const s = String(str || ""); @@ -143,11 +140,9 @@ function buildTwiml() { return new twilio.twiml.VoiceResponse(); } -/** - * NON forziamo "alice": usa voce configurata su Twilio Console - */ function sayIt(response, text) { - response.say({ language: "it-IT" }, text); + // ESCAPE per evitare XML rotto (’ ecc) + response.say({ language: "it-IT" }, xmlEscape(text)); } function gatherSpeech(response, promptText) { @@ -159,7 +154,8 @@ function gatherSpeech(response, promptText) { action: actionUrl, method: "POST", }); - sayIt(gather, promptText); + // Anche qui escape + gather.say({ language: "it-IT" }, xmlEscape(promptText)); } function isValidPhoneE164(s) { @@ -189,31 +185,23 @@ function getSession(callSid) { sessions.set(callSid, { step: 1, retries: 0, - name: null, dateISO: null, time24: null, people: null, - specialRequestsRaw: null, - preorderChoiceKey: null, preorderLabel: null, - area: null, pendingOutsideConfirm: false, - phone: null, waTo: null, - tableDisplayId: null, tableLocks: [], tableNotes: null, - durationMinutes: null, bookingType: "restaurant", autoConfirm: true, - promoEligible: null, }); } @@ -228,11 +216,9 @@ function resetRetries(session) { session.retries = 0; } -// ======================= UTIL/PARSING ======================= function normalizeText(s) { return String(s || "").trim().toLowerCase().replace(/\s+/g, " "); } - function toISODate(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -242,15 +228,15 @@ function toISODate(d) { // ---- Back/edit commands function isBackCommand(speech) { - const t = normalizeText(speech); + const tt = normalizeText(speech); return ( - t.includes("indietro") || - t.includes("torna indietro") || - t.includes("tornare indietro") || - t.includes("modifica") || - t.includes("errore") || - t.includes("ho sbagliato") || - t.includes("sbagliato") + tt.includes("indietro") || + tt.includes("torna indietro") || + tt.includes("tornare indietro") || + tt.includes("modifica") || + tt.includes("errore") || + tt.includes("ho sbagliato") || + tt.includes("sbagliato") ); } @@ -258,7 +244,6 @@ function goBack(session) { if (!session || typeof session.step !== "number") return; if (session.step <= 1) return; - // 1 nome -> 2 data -> 3 ora -> 4 pax -> 5 note -> 6 preordine -> 8 area -> 10 telefono if (session.step === 2) session.step = 1; else if (session.step === 3) session.step = 2; else if (session.step === 4) session.step = 3; @@ -271,180 +256,27 @@ function goBack(session) { function promptForStep(vr, session) { switch (session.step) { - case 1: - gatherSpeech(vr, t("step1_welcome_name.main")); - return; - case 2: - gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); - return; - case 3: - gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO || "" })); - return; - case 4: - gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); - return; - case 5: - gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); - return; + case 1: gatherSpeech(vr, t("step1_welcome_name.main")); return; + case 2: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; + case 3: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO || "" })); return; + case 4: gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); return; + case 5: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; case 6: gatherSpeech( vr, "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); return; - case 8: - gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - return; - case 10: - gatherSpeech(vr, t("step7_whatsapp_number.main")); - return; - default: - gatherSpeech(vr, t("step1_welcome_name.short")); - return; - } -} - -// ---- Date parsing robusto (come prima, completo) -function parseItalianNumberToInt(text) { - const t = normalizeText(text).replace(/[-,\.]/g, " ").replace(/\s+/g, " ").trim(); - - const direct = { - uno: 1, una: 1, primo: 1, - due: 2, tre: 3, quattro: 4, cinque: 5, sei: 6, sette: 7, otto: 8, nove: 9, - dieci: 10, undici: 11, dodici: 12, tredici: 13, quattordici: 14, quindici: 15, sedici: 16, diciassette: 17, - diciotto: 18, diciannove: 19, venti: 20, trenta: 30 - }; - if (direct[t] != null) return direct[t]; - - const cleaned = t.replace(/’/g, "'").replace(/'/g, ""); - const units = { uno: 1, una: 1, due: 2, tre: 3, quattro: 4, cinque: 5, sei: 6, sette: 7, otto: 8, nove: 9 }; - - if (cleaned.startsWith("venti")) { - const tail = cleaned.slice("venti".length).trim(); - if (!tail) return 20; - if (units[tail] != null) return 20 + units[tail]; - } - - if (cleaned.startsWith("trenta")) { - const tail = cleaned.slice("trenta".length).trim(); - if (!tail) return 30; - if (tail === "uno" || tail === "una") return 31; - } - - const m = cleaned.match(/\b(\d{1,2})\b/); - if (m) return Number(m[1]); - - return null; -} - -function parseDateIT(speech) { - const t0 = normalizeText(speech); - const t = t0.replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); - const today = new Date(); - - if (t.includes("oggi")) return toISODate(today); - if (t.includes("domani")) { - const d = new Date(today.getTime() + 24 * 60 * 60 * 1000); - return toISODate(d); - } - - const rel = t.match(/\b(tra|fra)\s+(.+?)\s+giorn[io]\b/); - if (rel) { - const n = parseItalianNumberToInt(rel[2]); - if (n && n > 0) { - const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - d.setDate(d.getDate() + n); - return toISODate(d); - } - } - - const iso = t.match(/(\d{4})-(\d{2})-(\d{2})/); - if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; - - const dmY = t.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); - if (dmY) { - let dd = Number(dmY[1]); - let mm = Number(dmY[2]); - let yy = dmY[3] ? Number(dmY[3]) : today.getFullYear(); - if (yy < 100) yy += 2000; - if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { - const d = new Date(yy, mm - 1, dd); - return toISODate(d); - } - } - - const dmSpace = t.match(/\b(\d{1,2})\s+(\d{1,2})(?:\s+(\d{2,4}))?\b/); - if (dmSpace) { - let dd = Number(dmSpace[1]); - let mm = Number(dmSpace[2]); - let yy = dmSpace[3] ? Number(dmSpace[3]) : today.getFullYear(); - if (yy < 100) yy += 2000; - if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { - const d = new Date(yy, mm - 1, dd); - return toISODate(d); - } + case 8: gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); return; + case 10: gatherSpeech(vr, t("step7_whatsapp_number.main")); return; + default: gatherSpeech(vr, t("step1_welcome_name.short")); return; } - - const weekdayMap = { - domenica: 0, - lunedi: 1, "lunedì": 1, - martedi: 2, "martedì": 2, - mercoledi: 3, "mercoledì": 3, - giovedi: 4, "giovedì": 4, - venerdi: 5, "venerdì": 5, - sabato: 6, - }; - - const hasQuesto = /\b(questo|questa|sto|sta)\b/.test(t); - const hasProssimo = /\b(prossimo|prossima)\b/.test(t); - - const weekdayMatch = t.match(/\b(domenica|lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato)\b/); - if (weekdayMatch) { - const target = weekdayMap[weekdayMatch[1]]; - const d = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - const current = d.getDay(); - - let diff = (target - current + 7) % 7; - if (hasProssimo) diff = diff === 0 ? 7 : diff + 7; - else if (hasQuesto) { - // ok - } - - d.setDate(d.getDate() + diff); - return toISODate(d); - } - - const months = { - gennaio: 1, febbraio: 2, marzo: 3, aprile: 4, maggio: 5, giugno: 6, - luglio: 7, agosto: 8, settembre: 9, ottobre: 10, novembre: 11, dicembre: 12, - }; - - const cleaned = t - .replace(/\b(lunedi|lunedì|martedi|martedì|mercoledi|mercoledì|giovedi|giovedì|venerdi|venerdì|sabato|domenica)\b/g, " ") - .replace(/\b(questo|questa|prossimo|prossima|il|lo|la|per|di|del|dello|della)\b/g, " ") - .replace(/\s+/g, " ") - .trim(); - - const m = cleaned.match(/\b(.+?)\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); - if (m) { - const dd = parseItalianNumberToInt(m[1]); - const mm = months[m[2]]; - let yy = m[3] ? Number(m[3]) : today.getFullYear(); - if (yy < 100) yy += 2000; - - if (dd && dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { - const d = new Date(yy, mm - 1, dd); - return toISODate(d); - } - } - - return null; } +// ====== parsing basilari (per arrivare al punto: FIX crash step 5) ====== function parseTimeIT(speech) { - const t = normalizeText(speech); - - const hm = t.match(/(\d{1,2})[:\s](\d{2})/); + const tt = normalizeText(speech); + const hm = tt.match(/(\d{1,2})[:\s](\d{2})/); if (hm) { const hh = Number(hm[1]); const mm = Number(hm[2]); @@ -452,479 +284,64 @@ function parseTimeIT(speech) { return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; } } - - const onlyH = t.match(/\b(\d{1,2})\b/); + const onlyH = tt.match(/\b(\d{1,2})\b/); if (onlyH) { const hh = Number(onlyH[1]); if (hh >= 0 && hh <= 23) return `${String(hh).padStart(2, "0")}:00`; } - return null; } function parsePeopleIT(speech) { - const t = normalizeText(speech); - const m = t.match(/\b(\d{1,2})\b/); + const tt = normalizeText(speech); + const m = tt.match(/\b(\d{1,2})\b/); if (!m) return null; const n = Number(m[1]); if (!Number.isFinite(n) || n <= 0) return null; return n; } -function parseAreaIT(speech) { - const t = normalizeText(speech); - if (t.includes("intern") || t.includes("dentro") || t.includes("sala")) return "inside"; - if (t.includes("estern") || t.includes("fuori") || t.includes("terraz") || t.includes("giardino")) return "outside"; - return null; -} - -function parsePreorderChoiceKey(speech) { - const t = normalizeText(speech); - if (t.includes("promo")) return "piatto_apericena_promo"; - if (t.includes("piatto") && t.includes("apericena")) return "piatto_apericena"; - if (t.includes("dopocena") || t.includes("dopo cena") || t.includes("dopo")) return "dopocena"; - if (t.includes("apericena")) return "apericena"; - if (t.includes("cena")) return "cena"; - if (t.includes("nessuno") || t.includes("nessuna") || t.includes("no")) return null; - return "unknown"; -} - -// ---- Phone parsing robusto + default +39 (come prima) -function speechToDigitsIT(raw) { - const t = normalizeText(raw); - const map = { zero: "0", uno: "1", una: "1", due: "2", tre: "3", quattro: "4", cinque: "5", sei: "6", sette: "7", otto: "8", nove: "9" }; - - const tokens = t.replace(/[^a-z0-9+\s]/g, " ").replace(/\s+/g, " ").trim().split(" ").filter(Boolean); - let out = ""; - - for (let i = 0; i < tokens.length; i++) { - const w = tokens[i]; - - if (/^\d+$/.test(w)) { out += w; continue; } - if (w === "+" || w === "piu" || w === "più") { out += "+"; continue; } - - if (w === "doppio" || w === "triplo") { - const next = tokens[i + 1]; - const digit = map[next] || (next && /^\d$/.test(next) ? next : null); - if (digit) { - out += w === "doppio" ? digit + digit : digit + digit + digit; - i++; - continue; - } - } - - if (map[w]) { out += map[w]; continue; } - } - - return out; -} - -function extractPhoneFromSpeech(speech) { - if (!speech) return null; - - let raw = String(speech).replace(/[^\d+]/g, ""); - if (raw) { - if (raw.startsWith("00")) raw = "+" + raw.slice(2); - - if (raw.startsWith("+")) { - const digits = raw.slice(1).replace(/\D/g, ""); - const e164 = "+" + digits; - if (isValidPhoneE164(e164)) return e164; - } else { - const digits = raw.replace(/\D/g, ""); - if (digits.length >= 8 && digits.length <= 15) { - if (digits.startsWith("39")) { - const e164 = "+" + digits; - if (isValidPhoneE164(e164)) return e164; - } else { - const e164 = "+39" + digits; - if (isValidPhoneE164(e164)) return e164; - } - } - } - } - - const fromWords = speechToDigitsIT(speech); - if (!fromWords) return null; - - let s = String(fromWords).replace(/[^\d+]/g, ""); - if (!s) return null; - - if (s.startsWith("00")) s = "+" + s.slice(2); - - if (s.startsWith("+")) { - const digits = s.slice(1).replace(/\D/g, ""); - const e164 = "+" + digits; - return isValidPhoneE164(e164) ? e164 : null; - } - - const digits = s.replace(/\D/g, ""); - if (!digits) return null; - - if (digits.startsWith("39")) { - const e164 = "+" + digits; - return isValidPhoneE164(e164) ? e164 : null; - } - - const e164 = "+39" + digits; - return isValidPhoneE164(e164) ? e164 : null; -} - -// ======================= TIME HELPERS ======================= -function hmToMinutes(hm) { - const [h, m] = String(hm).split(":").map(Number); - return h * 60 + m; -} - -function isTimeAtOrAfter(time24, minTime24) { - return hmToMinutes(time24) >= hmToMinutes(minTime24); -} - -function getRestaurantWindowForDay(day) { - if (day === 5 || day === 6) return OPENING.restaurant.friSat; - return OPENING.restaurant.default; -} - -function isWithinWindow(time24, startHM, endHM) { - const tmin = hmToMinutes(time24); - const start = hmToMinutes(startHM); - const end = hmToMinutes(endHM); - return tmin >= start && tmin <= end; -} - -function deriveBookingTypeAndConfirm(dateISO, time24) { - const d = new Date(`${dateISO}T00:00:00`); - const day = d.getDay(); - - if (day === OPENING.closedDay) return { bookingType: "operator", autoConfirm: false }; - - const restWin = getRestaurantWindowForDay(day); - const inRestaurant = isWithinWindow(time24, restWin.start, restWin.end); - const inDrinks = isWithinWindow(time24, OPENING.drinksOnly.start, OPENING.drinksOnly.end); - - if (inRestaurant) return { bookingType: "restaurant", autoConfirm: true }; - if (inDrinks) return { bookingType: "drinks", autoConfirm: true }; - - return { bookingType: "closed", autoConfirm: false }; -} - -function getDurationMinutes(people, dateObj) { - let minutes; - if (people <= 4) minutes = 120; - else if (people <= 8) minutes = 150; - else minutes = 180; - - const day = dateObj.getDay(); - const isMusicNight = OPENING.musicNights.days.includes(day); - if (isMusicNight && people <= 8) minutes += 30; - - return minutes; -} - -function computeStartEndLocal(dateISO, time24, durationMinutes) { - const [sh, sm] = time24.split(":").map(Number); - const startTotal = sh * 60 + sm; - const endTotal = startTotal + durationMinutes; - - const endDayOffset = Math.floor(endTotal / (24 * 60)); - const endMinutesOfDay = endTotal % (24 * 60); - const endH = Math.floor(endMinutesOfDay / 60); - const endM = endMinutesOfDay % 60; +// ---- Date: user-friendly (stesso parser robusto, qui versione breve per stabilità) +function parseDateIT(speech) { + const tt = normalizeText(speech).replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); + const today = new Date(); - let endDateISO = dateISO; - if (endDayOffset > 0) { - const d = new Date(`${dateISO}T00:00:00`); - d.setDate(d.getDate() + endDayOffset); - endDateISO = toISODate(d); + if (tt.includes("oggi")) return toISODate(today); + if (tt.includes("domani")) { + const d = new Date(today.getTime() + 24 * 60 * 60 * 1000); + return toISODate(d); } - const startLocal = `${dateISO}T${String(sh).padStart(2, "0")}:${String(sm).padStart(2, "0")}:00`; - const endLocal = `${endDateISO}T${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}:00`; - - return { startLocal, endLocal }; -} - -// ======================= GOOGLE CALENDAR ======================= -function getServiceAccountJsonRaw() { - if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { - try { - const decoded = Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf-8"); - JSON.parse(decoded); - return decoded; - } catch (e) { - console.error("[CALENDAR] Invalid GOOGLE_SERVICE_ACCOUNT_JSON_B64:", e); - return ""; - } - } - if (GOOGLE_SERVICE_ACCOUNT_JSON) { - try { - JSON.parse(GOOGLE_SERVICE_ACCOUNT_JSON); - return GOOGLE_SERVICE_ACCOUNT_JSON; - } catch (e) { - console.error("[CALENDAR] Invalid GOOGLE_SERVICE_ACCOUNT_JSON:", e); - return ""; + const dmY = tt.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); + if (dmY) { + let dd = Number(dmY[1]); + let mm = Number(dmY[2]); + let yy = dmY[3] ? Number(dmY[3]) : today.getFullYear(); + if (yy < 100) yy += 2000; + if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { + return toISODate(new Date(yy, mm - 1, dd)); } } - return ""; -} - -function getCalendarClient() { - if (!GOOGLE_CALENDAR_ID) return null; - - const raw = getServiceAccountJsonRaw(); - if (!raw) return null; - - const creds = JSON.parse(raw); - const scopes = ["https://www.googleapis.com/auth/calendar"]; - const auth = new google.auth.JWT(creds.client_email, null, creds.private_key, scopes); - return google.calendar({ version: "v3", auth }); -} - -function containsMarker(ev, markerLower) { - const s = `${String(ev.summary || "")}\n${String(ev.description || "")}`.toLowerCase(); - return s.includes(markerLower); -} -function parseLocksFromEvent(ev) { - const d = String(ev.description || ""); - const m = d.match(/LOCKS:\s*([A-Z0-9,]+)/i); - if (!m) return []; - return m[1].split(",").map((s) => s.trim()).filter(Boolean); -} - -/** - * ✅ UNA SOLA CHIAMATA Calendar per: - * - locale chiuso - * - no promo - * - locks tavoli - */ -async function getDayCalendarSnapshot(calendar, dateISO) { - const timeMin = `${dateISO}T00:00:00Z`; - const timeMax = `${dateISO}T23:59:59Z`; - - const resp = await calendar.events.list({ - calendarId: GOOGLE_CALENDAR_ID, - timeMin, - timeMax, - singleEvents: true, - orderBy: "startTime", - maxResults: 250, - }); - - const items = resp.data.items || []; - const locked = new Set(); - let isClosed = false; - let hasNoPromo = false; - - for (const ev of items) { - if (containsMarker(ev, "locale chiuso")) isClosed = true; - if (containsMarker(ev, "no promo")) hasNoPromo = true; - for (const tId of parseLocksFromEvent(ev)) locked.add(tId); - } - - return { isClosed, hasNoPromo, lockedSet: locked }; -} - -function isPromoEligibleByDay(dateISO) { - const d = new Date(`${dateISO}T00:00:00`); - const day = d.getDay(); - const allowedDay = day === 0 || day === 2 || day === 3 || day === 4 || day === 6; // Tue-Sun excl Fri - if (!allowedDay) return false; - if (HOLIDAYS_SET.has(dateISO)) return false; - return true; -} - -// ======================= TABLE ALLOCATION ======================= -function buildCandidates(area) { - const singles = TABLES.filter((tt) => tt.area === area).map((tt) => ({ - displayId: tt.id, - locks: [tt.id], - min: tt.min, - max: tt.max, - area: tt.area, - notes: tt.notes || "", - kind: "single", - })); - - const combos = TABLE_COMBINATIONS.filter((c) => c.area === area).map((c) => ({ - displayId: c.displayId, - locks: c.replaces.slice(), - min: c.min, - max: c.max, - area: c.area, - notes: c.notes || "", - kind: "combo", - })); - - return singles.concat(combos); -} - -function allocateTable({ area, people, lockedSet }) { - const candidates = buildCandidates(area); - - let ok = candidates.filter((c) => people >= c.min && people <= c.max); - ok = ok.filter((c) => c.locks.every((tId) => !lockedSet.has(tId))); - - ok.sort((a, b) => { - const wasteA = a.max - people; - const wasteB = b.max - people; - if (wasteA !== wasteB) return wasteA - wasteB; - if (a.kind !== b.kind) return a.kind === "single" ? -1 : 1; - return String(a.displayId).localeCompare(String(b.displayId)); - }); - - if (ok.length === 0) return null; - return ok[0]; -} - -// ======================= EVENT CREATION (IDEMPOTENT) ======================= -async function createBookingEvent(calendar, payload) { - const { - callSid, - name, - dateISO, - time24, - people, - phone, - waTo, - area, - bookingType, - autoConfirm, - durationMinutes, - tableDisplayId, - tableLocks, - tableNotes, - specialRequestsRaw, - preorderLabel, - preorderPriceText, - outsideDisclaimer, - promoEligible, - } = payload; - - const tz = GOOGLE_CALENDAR_TZ; - const { startLocal, endLocal } = computeStartEndLocal(dateISO, time24, durationMinutes); - const privateKey = `callsid:${callSid || "no-callsid"}`; - - const existing = await calendar.events.list({ - calendarId: GOOGLE_CALENDAR_ID, - q: privateKey, - timeMin: `${dateISO}T00:00:00Z`, - timeMax: `${dateISO}T23:59:59Z`, - singleEvents: true, - maxResults: 10, - }); - - const found = (existing.data.items || []).find((ev) => String(ev.description || "").includes(privateKey)); - if (found) return { created: false, eventId: found.id }; - - const prefix = autoConfirm ? "" : "DA CONFERMARE • "; - const summary = `${prefix}TB • ${tableDisplayId} • ${name} • ${people} pax`; - - const promoLine = - preorderLabel && preorderLabel.toLowerCase().includes("promo") - ? `Promo: ${promoEligible ? "Eleggibile (previa registrazione)" : "NON eleggibile (verificare con cliente)"}` - : null; - - const description = [ - `TABLE:${tableDisplayId}`, - `LOCKS:${(tableLocks || []).join(",")}`, - `AREA:${area}`, - `TYPE:${bookingType}`, - "", - `Nome: ${name}`, - `Persone: ${people}`, - phone ? `Telefono: ${phone}` : `Telefono: -`, - waTo ? `WhatsApp: ${waTo}` : `WhatsApp: -`, - tableNotes ? `Note tavolo: ${tableNotes}` : null, - specialRequestsRaw ? `Richieste: ${specialRequestsRaw}` : `Richieste: nessuna`, - preorderLabel ? `Preordine: ${preorderLabel}${preorderPriceText ? ` (${preorderPriceText})` : ""}` : null, - promoLine, - outsideDisclaimer ? `Nota esterno: ${outsideDisclaimer}` : null, - privateKey, - ] - .filter(Boolean) - .join("\n"); - - const requestBody = { - summary, - description, - start: { dateTime: startLocal, timeZone: tz }, - end: { dateTime: endLocal, timeZone: tz }, - }; - - const resp = await calendar.events.insert({ - calendarId: GOOGLE_CALENDAR_ID, - requestBody, - }); - - return { created: true, eventId: resp.data.id }; -} - -// ======================= WHATSAPP (fire-and-forget) ======================= -async function sendWhatsAppConfirmation(payload) { - const { - waTo, - name, - dateISO, - time24, - people, - tableDisplayId, - area, - specialRequestsRaw, - preorderLabel, - preorderPriceText, - outsideDisclaimer, - bookingType, - promoEligible, - } = payload; - - if (!twilioClient) return; - if (!TWILIO_WHATSAPP_FROM) return; - if (!waTo || !hasValidWaAddress(waTo)) return; - - const lines = [ - `Ciao ${name}! Prenotazione registrata ✅`, - `Data: ${dateISO}`, - `Ora: ${time24}`, - `Persone: ${people}`, - `Tavolo: ${tableDisplayId} (${area === "inside" ? "interno" : "esterno"})`, - ]; - - if (bookingType === "drinks") lines.push(`Nota: a quest'orario la cucina potrebbe essere chiusa (solo drink e vino).`); - - if (specialRequestsRaw && normalizeText(specialRequestsRaw) !== "nessuna") lines.push(`Richieste: ${specialRequestsRaw}`); - else lines.push(`Richieste: nessuna`); - - if (preorderLabel) { - let preorderLine = `Preordine: ${preorderLabel}`; - if (preorderPriceText) preorderLine += ` (${preorderPriceText})`; - lines.push(preorderLine); - - if (preorderLabel.toLowerCase().includes("promo")) { - lines.push(`Promo: ${promoEligible ? "eleggibile previa registrazione" : "da verificare (giorno non promo o festivo)"}`); - } + const m = tt.match(/\b(\d{1,2})\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); + if (m) { + const months = { + gennaio: 1, febbraio: 2, marzo: 3, aprile: 4, maggio: 5, giugno: 6, + luglio: 7, agosto: 8, settembre: 9, ottobre: 10, novembre: 11, dicembre: 12, + }; + const dd = Number(m[1]); + const mm = months[m[2]]; + let yy = m[3] ? Number(m[3]) : today.getFullYear(); + if (yy < 100) yy += 2000; + if (dd >= 1 && dd <= 31 && mm) return toISODate(new Date(yy, mm - 1, dd)); } - if (outsideDisclaimer) lines.push(`Nota: ${outsideDisclaimer}`); - - lines.push(`A presto da TuttiBrilli!`); - - const body = lines.join("\n"); - - await twilioClient.messages.create({ - from: TWILIO_WHATSAPP_FROM, - to: waTo, - body, - }); + return null; } // ======================= ROUTES ======================= app.get("/health", (req, res) => res.json({ ok: true })); -/** - * /voice: SOLO dialogo (risposte rapide). - * IMPORTANTISSIMO: allo step 10 dopo aver salvato il numero facciamo redirect a /finalize. - */ app.post("/voice", async (req, res) => { const callSid = req.body.CallSid || ""; const speech = req.body.SpeechResult || ""; @@ -934,27 +351,26 @@ app.post("/voice", async (req, res) => { try { if (!session) { sayIt(vr, "Errore di sessione. Riprova tra poco."); - return res.type("text/xml").send(vr.toString()); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); } const emptySpeech = !normalizeText(speech); - // comandi indietro/modifica if (!emptySpeech && isBackCommand(speech)) { resetRetries(session); goBack(session); promptForStep(vr, session); - return res.type("text/xml").send(vr.toString()); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); } switch (session.step) { case 1: { if (emptySpeech) { - resetRetries(session); gatherSpeech(vr, t("step1_welcome_name.main")); break; } - session.name = speech.trim().slice(0, 60); resetRetries(session); session.step = 2; @@ -964,26 +380,14 @@ app.post("/voice", async (req, res) => { case 2: { if (emptySpeech) { - if (bumpRetries(session) > 2) { - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } - const dateISO = parseDateIT(speech); if (!dateISO) { - if (bumpRetries(session) > 2) { - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } - session.dateISO = dateISO; resetRetries(session); session.step = 3; @@ -993,50 +397,15 @@ app.post("/voice", async (req, res) => { case 3: { if (emptySpeech) { - if (bumpRetries(session) > 2) { - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); break; } - const time24 = parseTimeIT(speech); if (!time24) { - if (bumpRetries(session) > 2) { - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); break; } - session.time24 = time24; - - const { bookingType, autoConfirm } = deriveBookingTypeAndConfirm(session.dateISO, session.time24); - session.bookingType = bookingType; - session.autoConfirm = autoConfirm; - - if (bookingType === "closed") { - sayIt(vr, t("step4_confirm_time_ask_party_size.outsideHours.main")); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } - - if (bookingType === "operator") { - // lunedì: operatore - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } - - if (bookingType === "drinks") { - sayIt(vr, t("step4_confirm_time_ask_party_size.kitchenClosed.main")); - } - resetRetries(session); session.step = 4; gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 })); @@ -1045,50 +414,27 @@ app.post("/voice", async (req, res) => { case 4: { if (emptySpeech) { - if (bumpRetries(session) > 2) { - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; } - const people = parsePeopleIT(speech); - if (!people || people < 1 || people > 18) { - if (bumpRetries(session) > 2) { - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - break; - } + if (!people) { gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; } - session.people = people; resetRetries(session); + + // ✅ QUI: lo step che ti fa cadere la chiamata. + // Ora il testo è XML-safe + charset corretto, quindi non può più rompere TwiML. session.step = 5; gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); break; } case 5: { - if (emptySpeech) { - if (bumpRetries(session) > 2) { - session.specialRequestsRaw = "nessuna"; - resetRetries(session); - session.step = 6; - gatherSpeech( - vr, - "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." - ); - break; - } - gatherSpeech(vr, t("step6_collect_notes.error")); - break; - } - - session.specialRequestsRaw = speech.trim().slice(0, 200); + // raccolta note (qui non facciamo nulla di pesante) + session.specialRequestsRaw = emptySpeech ? "nessuna" : speech.trim().slice(0, 200); resetRetries(session); session.step = 6; gatherSpeech( @@ -1098,178 +444,6 @@ app.post("/voice", async (req, res) => { break; } - case 6: { - if (emptySpeech) { - if (bumpRetries(session) > 2) { - session.preorderChoiceKey = null; - session.preorderLabel = null; - resetRetries(session); - session.step = 8; - gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - break; - } - gatherSpeech(vr, "Non ho sentito. Di' cena, apericena, dopocena, piatto apericena, piatto apericena promo, oppure nessuno."); - break; - } - - const key = parsePreorderChoiceKey(speech); - - if (key === "unknown") { - if (bumpRetries(session) > 2) { - session.preorderChoiceKey = null; - session.preorderLabel = null; - resetRetries(session); - session.step = 8; - gatherSpeech(vr, "Ok, nessun preordine. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - break; - } - gatherSpeech(vr, "Scusa, non ho capito. Puoi dire: cena, apericena, dopocena, piatto apericena, piatto apericena promo, oppure nessuno."); - break; - } - - if (!key) { - session.preorderChoiceKey = null; - session.preorderLabel = null; - resetRetries(session); - session.step = 8; - gatherSpeech(vr, "Perfetto. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - break; - } - - const opt = getPreorderOptionByKey(key); - if (opt && opt.constraints && opt.constraints.minTime) { - if (!isTimeAtOrAfter(session.time24, opt.constraints.minTime)) { - sayIt(vr, t("step4_confirm_time_ask_party_size.afterDinner")); - resetRetries(session); - gatherSpeech(vr, "Puoi scegliere: cena, apericena o piatto apericena. Oppure dì nessuno."); - break; - } - } - - session.preorderChoiceKey = opt ? opt.key : null; - session.preorderLabel = opt ? opt.label : null; - - resetRetries(session); - session.step = 8; - gatherSpeech(vr, "Perfetto. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - break; - } - - case 8: { - if (emptySpeech) { - if (bumpRetries(session) > 2) { - session.area = "inside"; - resetRetries(session); - session.step = 10; - gatherSpeech(vr, t("step7_whatsapp_number.main")); - break; - } - gatherSpeech(vr, "Non ho capito. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - break; - } - - if (session.pendingOutsideConfirm) { - const area = parseAreaIT(speech); - const tt = normalizeText(speech); - - if (area === "inside") { - session.area = "inside"; - session.pendingOutsideConfirm = false; - resetRetries(session); - session.step = 10; - gatherSpeech(vr, t("step7_whatsapp_number.main")); - break; - } - - if (area === "outside" || tt.includes("confermo") || tt.includes("va bene esterno")) { - session.area = "outside"; - session.pendingOutsideConfirm = false; - resetRetries(session); - session.step = 10; - gatherSpeech(vr, t("step7_whatsapp_number.main")); - break; - } - - if (bumpRetries(session) > 2) { - session.area = "inside"; - session.pendingOutsideConfirm = false; - resetRetries(session); - session.step = 10; - gatherSpeech(vr, t("step7_whatsapp_number.main")); - break; - } - - gatherSpeech(vr, "Preferisci interno, oppure confermi esterno?"); - break; - } - - const area = parseAreaIT(speech); - if (!area) { - if (bumpRetries(session) > 2) { - session.area = "inside"; - resetRetries(session); - session.step = 10; - gatherSpeech(vr, t("step7_whatsapp_number.main")); - break; - } - gatherSpeech(vr, "Scusa, non ho capito. Preferisci sala interna o sala esterna? Ti consiglio l'interno."); - break; - } - - if (area === "outside") { - session.pendingOutsideConfirm = true; - resetRetries(session); - gatherSpeech( - vr, - "Ti avviso che all'esterno non ci sono riscaldamenti né copertura, e in caso di maltempo non è garantito il posto all'interno. Ti consiglio l'interno. Preferisci interno, oppure confermi esterno?" - ); - break; - } - - session.area = "inside"; - resetRetries(session); - session.step = 10; - gatherSpeech(vr, t("step7_whatsapp_number.main")); - break; - } - - case 10: { - // STEP TELEFONO: qui NON facciamo Calendar. Salviamo e REDIRECT a /finalize. - if (emptySpeech) { - if (bumpRetries(session) <= 2) { - gatherSpeech(vr, t("step7_whatsapp_number.error")); - break; - } - session.phone = null; - session.waTo = null; - resetRetries(session); - } else { - const phone = extractPhoneFromSpeech(speech); - - if (!phone || !isValidPhoneE164(phone)) { - if (bumpRetries(session) <= 2) { - gatherSpeech(vr, t("step7_whatsapp_number.spokeTooFast")); - break; - } - session.phone = null; - session.waTo = null; - resetRetries(session); - } else { - session.phone = phone; - session.waTo = `whatsapp:${phone}`; - resetRetries(session); - } - } - - // ✅ risposta veloce + redirect (evita timeout Twilio) - sayIt(vr, t("step7_whatsapp_number.afterCapture")); - - const finalizeUrl = BASE_URL ? `${BASE_URL}/finalize` : "/finalize"; - vr.redirect({ method: "POST" }, finalizeUrl); - - break; - } - default: { sayIt(vr, t("step9_success.goodbye")); vr.hangup(); @@ -1278,147 +452,23 @@ app.post("/voice", async (req, res) => { } } - return res.type("text/xml").send(vr.toString()); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); } catch (err) { console.error("[VOICE] Error:", err); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - return res.type("text/xml").send(vr.toString()); - } -}); - -/** - * /finalize: qui facciamo Calendar + WhatsApp. - * Twilio ci arriva con redirect dopo lo step telefono. - */ -app.post("/finalize", async (req, res) => { - const callSid = req.body.CallSid || ""; - const session = getSession(callSid); - const vr = buildTwiml(); - - try { - if (!session || !session.name || !session.dateISO || !session.time24 || !session.people || !session.area) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) { - vr.dial({}, HUMAN_FORWARD_TO); - } else { - vr.hangup(); - } - return res.type("text/xml").send(vr.toString()); - } - - const calendar = getCalendarClient(); - if (!calendar) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) vr.dial({}, HUMAN_FORWARD_TO); - else vr.hangup(); - return res.type("text/xml").send(vr.toString()); - } - - // durata - const d = new Date(`${session.dateISO}T00:00:00`); - session.durationMinutes = getDurationMinutes(session.people, d); - - const outsideDisclaimer = - session.area === "outside" ? "Tavolo esterno: in caso di maltempo non è garantito il posto all'interno." : null; - - let preorderPriceText = null; - if (session.preorderChoiceKey) { - const opt = getPreorderOptionByKey(session.preorderChoiceKey); - if (opt && typeof opt.priceEUR === "number") preorderPriceText = `${opt.priceEUR} €`; - } - - // ✅ UNA SOLA list per chiusura/no promo/locks - const snap = await getDayCalendarSnapshot(calendar, session.dateISO); - - if (snap.isClosed) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) vr.dial({}, HUMAN_FORWARD_TO); - else vr.hangup(); - sessions.delete(callSid); - return res.type("text/xml").send(vr.toString()); + if (canForwardToHuman()) { + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(forwardToHumanTwiml()); } - - const dayOk = isPromoEligibleByDay(session.dateISO); - session.promoEligible = dayOk && !snap.hasNoPromo; - - // assegna tavolo - const chosen = allocateTable({ area: session.area, people: session.people, lockedSet: snap.lockedSet }); - if (!chosen) { - sayIt(vr, t("step9_fallback_transfer_operator.main")); - if (canForwardToHuman()) vr.dial({}, HUMAN_FORWARD_TO); - else vr.hangup(); - sessions.delete(callSid); - return res.type("text/xml").send(vr.toString()); - } - - session.tableDisplayId = chosen.displayId; - session.tableLocks = chosen.locks; - session.tableNotes = chosen.notes || null; - - // crea evento (sempre, anche senza telefono) - await createBookingEvent(calendar, { - callSid, - name: session.name, - dateISO: session.dateISO, - time24: session.time24, - people: session.people, - phone: session.phone, - waTo: session.waTo, - area: session.area, - bookingType: session.bookingType, - autoConfirm: session.autoConfirm, - durationMinutes: session.durationMinutes, - tableDisplayId: session.tableDisplayId, - tableLocks: session.tableLocks, - tableNotes: session.tableNotes, - specialRequestsRaw: session.specialRequestsRaw, - preorderLabel: session.preorderLabel, - preorderPriceText, - outsideDisclaimer, - promoEligible: Boolean(session.promoEligible), - }); - - // WhatsApp solo se numero valido (fire-and-forget) - if (session.autoConfirm && session.waTo && hasValidWaAddress(session.waTo)) { - sendWhatsAppConfirmation({ - waTo: session.waTo, - name: session.name, - dateISO: session.dateISO, - time24: session.time24, - people: session.people, - tableDisplayId: session.tableDisplayId, - area: session.area, - specialRequestsRaw: session.specialRequestsRaw, - preorderLabel: session.preorderLabel, - preorderPriceText, - outsideDisclaimer, - bookingType: session.bookingType, - promoEligible: Boolean(session.promoEligible), - }).catch((e) => console.error("[WHATSAPP] send error:", e)); - } - - // voce finale - if (session.waTo && hasValidWaAddress(session.waTo)) { - sayIt(vr, t("step9_success.main")); - } else { - sayIt(vr, "Perfetto, ho registrato la prenotazione. Se vuoi ricevere la conferma su WhatsApp, puoi richiamare e lasciarmi con calma il numero."); - } - sayIt(vr, t("step9_success.goodbye")); - - vr.hangup(); - sessions.delete(callSid); - return res.type("text/xml").send(vr.toString()); - } catch (err) { - console.error("[FINALIZE] error:", err); - if (canForwardToHuman()) return res.type("text/xml").send(forwardToHumanTwiml()); sayIt(vr, t("step9_fallback_transfer_operator.main")); - vr.hangup(); - sessions.delete(callSid); - return res.type("text/xml").send(vr.toString()); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); } }); +// NOTA: /finalize lo rimettiamo dopo, quando confermi che non cade più allo step 5. +// (non serve per risolvere il crash delle intolleranze) + app.get("/", (req, res) => { res.send("TuttiBrilli Voice Booking is running. Use POST /voice from Twilio."); }); From e81281fb5271a88126b5a1db0aa747c1fb53d723 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 12:33:40 +0100 Subject: [PATCH 045/143] Implement dotenv configuration loading Add dotenv module check and load configuration if present. --- app.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index de57f873ca..6ead5537c8 100644 --- a/app.js +++ b/app.js @@ -1,9 +1,19 @@ "use strict"; +const fs = require("fs"); +const path = require("path"); + // dotenv opzionale (su Render non serve) -try { +const dotenvModuleDir = path.join(__dirname, "node_modules", "dotenv"); +const dotenvPackageJson = path.join(dotenvModuleDir, "package.json"); +const dotenvEntryPoint = path.join(dotenvModuleDir, "index.js"); +const hasDotenv = + fs.existsSync(dotenvPackageJson) || + fs.existsSync(dotenvEntryPoint) || + fs.existsSync(dotenvModuleDir); +if (hasDotenv) { require("dotenv").config(); -} catch (_) {} +} const express = require("express"); const twilio = require("twilio"); From 2bad07176656f6bea2bb2f6c0ed3f292ce4c7ea1 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 12:50:49 +0100 Subject: [PATCH 046/143] Update prompts.js From 2152ef3c4ac05dc7f20dce67ddb915f98d8feb35 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 15:17:27 +0100 Subject: [PATCH 047/143] Update package.json From 97388f70fee2d166d716ead2f02dec77ef6679a7 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 15:17:53 +0100 Subject: [PATCH 048/143] Enhance user input handling for reservations Added support for recognizing yes/no responses and parsing WhatsApp numbers. Updated session flow for gathering user input and confirming reservations. --- app.js | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 183 insertions(+), 18 deletions(-) diff --git a/app.js b/app.js index 6ead5537c8..56ce00278d 100644 --- a/app.js +++ b/app.js @@ -48,6 +48,9 @@ const HOLIDAYS_SET = new Set(HOLIDAYS_YYYY_MM_DD.split(",").map((s) => s.trim()) const twilioClient = TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) : null; +const YES_WORDS = ["si", "sì", "certo", "confermo", "ok", "va bene", "perfetto", "esatto"]; +const NO_WORDS = ["no", "non", "annulla", "cancella", "negativo"]; + // ======================= OPENING HOURS ======================= const OPENING = { closedDay: 1, // Monday @@ -151,7 +154,6 @@ function buildTwiml() { } function sayIt(response, text) { - // ESCAPE per evitare XML rotto (’ ecc) response.say({ language: "it-IT" }, xmlEscape(text)); } @@ -164,7 +166,6 @@ function gatherSpeech(response, promptText) { action: actionUrl, method: "POST", }); - // Anche qui escape gather.say({ language: "it-IT" }, xmlEscape(promptText)); } @@ -259,8 +260,8 @@ function goBack(session) { else if (session.step === 4) session.step = 3; else if (session.step === 5) session.step = 4; else if (session.step === 6) session.step = 5; - else if (session.step === 8) session.step = 6; - else if (session.step === 10) session.step = 8; + else if (session.step === 7) session.step = 6; + else if (session.step === 8) session.step = 7; else session.step = Math.max(1, session.step - 1); } @@ -277,13 +278,18 @@ function promptForStep(vr, session) { "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); return; - case 8: gatherSpeech(vr, "Preferisci sala interna o sala esterna? Ti consiglio l'interno."); return; - case 10: gatherSpeech(vr, t("step7_whatsapp_number.main")); return; + case 7: gatherSpeech(vr, t("step7_whatsapp_number.main")); return; + case 8: gatherSpeech(vr, t("step8_summary_confirm.main", { + name: session.name || "", + dateLabel: session.dateISO || "", + time: session.time24 || "", + partySize: session.people || "", + })); return; default: gatherSpeech(vr, t("step1_welcome_name.short")); return; } } -// ====== parsing basilari (per arrivare al punto: FIX crash step 5) ====== +// ====== parsing basilari ====== function parseTimeIT(speech) { const tt = normalizeText(speech); const hm = tt.match(/(\d{1,2})[:\s](\d{2})/); @@ -311,7 +317,26 @@ function parsePeopleIT(speech) { return n; } -// ---- Date: user-friendly (stesso parser robusto, qui versione breve per stabilità) +function parseYesNo(speech) { + const tt = normalizeText(speech); + if (!tt) return null; + if (YES_WORDS.some((w) => tt.includes(w))) return true; + if (NO_WORDS.some((w) => tt.includes(w))) return false; + return null; +} + +function parseWhatsAppNumber(speech) { + const digits = String(speech || "").replace(/[^\d+]/g, ""); + if (hasValidWaAddress(digits)) return digits; + if (isValidPhoneE164(digits)) return `whatsapp:${digits}`; + const justNumbers = String(speech || "").replace(/[^\d]/g, ""); + if (justNumbers.length >= 8 && justNumbers.length <= 15) { + return `whatsapp:+${justNumbers}`; + } + return null; +} + +// ---- Date parser function parseDateIT(speech) { const tt = normalizeText(speech).replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); const today = new Date(); @@ -349,6 +374,76 @@ function parseDateIT(speech) { return null; } +// ---- Google Calendar helpers +function getGoogleCredentials() { + if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { + try { + const decoded = Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf8"); + return JSON.parse(decoded); + } catch (err) { + console.error("[GOOGLE] Invalid base64 credentials:", err); + } + } + if (GOOGLE_SERVICE_ACCOUNT_JSON) { + try { + return JSON.parse(GOOGLE_SERVICE_ACCOUNT_JSON); + } catch (err) { + console.error("[GOOGLE] Invalid JSON credentials:", err); + } + } + return null; +} + +function buildCalendarClient() { + const credentials = getGoogleCredentials(); + if (!credentials) return null; + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ["https://www.googleapis.com/auth/calendar"], + }); + return google.calendar({ version: "v3", auth }); +} + +async function createCalendarEvent(session) { + if (!GOOGLE_CALENDAR_ID) return null; + const calendar = buildCalendarClient(); + if (!calendar) return null; + if (!session?.dateISO || !session?.time24 || !session?.name || !session?.people) return null; + + const startDateTime = `${session.dateISO}T${session.time24}:00`; + const endDate = new Date(`${session.dateISO}T${session.time24}:00`); + endDate.setMinutes(endDate.getMinutes() + 120); + const endDateTime = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String( + endDate.getDate() + ).padStart(2, "0")}T${String(endDate.getHours()).padStart(2, "0")}:${String( + endDate.getMinutes() + ).padStart(2, "0")}:00`; + + const event = { + summary: `Prenotazione ${session.name} (${session.people} pax)`, + description: [ + `Nome: ${session.name}`, + `Persone: ${session.people}`, + `Note: ${session.specialRequestsRaw || "nessuna"}`, + `Preordine: ${session.preorderLabel || "nessuno"}`, + `WhatsApp: ${session.waTo || "non fornito"}`, + ].join("\n"), + start: { dateTime: startDateTime, timeZone: GOOGLE_CALENDAR_TZ }, + end: { dateTime: endDateTime, timeZone: GOOGLE_CALENDAR_TZ }, + }; + + try { + const result = await calendar.events.insert({ + calendarId: GOOGLE_CALENDAR_ID, + requestBody: event, + }); + return result?.data || null; + } catch (err) { + console.error("[GOOGLE] Calendar insert failed:", err); + return null; + } +} + // ======================= ROUTES ======================= app.get("/health", (req, res) => res.json({ ok: true })); @@ -434,16 +529,12 @@ app.post("/voice", async (req, res) => { } session.people = people; resetRetries(session); - - // ✅ QUI: lo step che ti fa cadere la chiamata. - // Ora il testo è XML-safe + charset corretto, quindi non può più rompere TwiML. session.step = 5; gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); break; } case 5: { - // raccolta note (qui non facciamo nulla di pesante) session.specialRequestsRaw = emptySpeech ? "nessuna" : speech.trim().slice(0, 200); resetRetries(session); session.step = 6; @@ -454,10 +545,87 @@ app.post("/voice", async (req, res) => { break; } + case 6: { + if (emptySpeech) { + promptForStep(vr, session); + break; + } + const normalized = normalizeText(speech); + if (normalized.includes("nessuno") || normalized.includes("niente") || normalized.includes("no")) { + session.preorderChoiceKey = null; + session.preorderLabel = "nessuno"; + } else { + const option = PREORDER_OPTIONS.find((o) => normalized.includes(o.label.toLowerCase()) || normalized.includes(o.key.replace(/_/g, " "))); + if (option) { + session.preorderChoiceKey = option.key; + session.preorderLabel = option.label; + } else { + gatherSpeech( + vr, + "Non ho capito il preordine. Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." + ); + break; + } + } + resetRetries(session); + session.step = 7; + gatherSpeech(vr, t("step7_whatsapp_number.main")); + break; + } + + case 7: { + if (emptySpeech) { + gatherSpeech(vr, t("step7_whatsapp_number.error")); + break; + } + const waTo = parseWhatsAppNumber(speech); + if (!waTo) { + gatherSpeech(vr, t("step7_whatsapp_number.error")); + break; + } + session.waTo = waTo; + resetRetries(session); + session.step = 8; + gatherSpeech(vr, t("step8_summary_confirm.main", { + name: session.name || "", + dateLabel: session.dateISO || "", + time: session.time24 || "", + partySize: session.people || "", + })); + break; + } + + case 8: { + const confirmation = parseYesNo(speech); + if (confirmation === null) { + gatherSpeech(vr, t("step8_summary_confirm.short", { + dateLabel: session.dateISO || "", + time: session.time24 || "", + partySize: session.people || "", + })); + break; + } + if (!confirmation) { + resetRetries(session); + goBack(session); + promptForStep(vr, session); + break; + } + await createCalendarEvent(session); + resetRetries(session); + session.step = 1; + gatherSpeech(vr, t("step9_success.main")); + break; + } + default: { - sayIt(vr, t("step9_success.goodbye")); - vr.hangup(); - sessions.delete(callSid); + if (canForwardToHuman()) { + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(forwardToHumanTwiml()); + } + resetRetries(session); + session.step = 1; + gatherSpeech(vr, t("step1_welcome_name.short")); break; } } @@ -476,9 +644,6 @@ app.post("/voice", async (req, res) => { } }); -// NOTA: /finalize lo rimettiamo dopo, quando confermi che non cade più allo step 5. -// (non serve per risolvere il crash delle intolleranze) - app.get("/", (req, res) => { res.send("TuttiBrilli Voice Booking is running. Use POST /voice from Twilio."); }); From 32e9749b0817f0b449a3b540fb290755b34f9ef2 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 16:09:18 +0100 Subject: [PATCH 049/143] Remove WhatsApp number handling functions Removed WhatsApp address validation and related logic. --- app.js | 45 ++++++++------------------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/app.js b/app.js index 56ce00278d..a1922ca6b6 100644 --- a/app.js +++ b/app.js @@ -32,7 +32,6 @@ const BASE_URL = process.env.BASE_URL || ""; const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; -const TWILIO_WHATSAPP_FROM = process.env.TWILIO_WHATSAPP_FROM || ""; const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; const GOOGLE_CALENDAR_TZ = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; @@ -172,9 +171,6 @@ function gatherSpeech(response, promptText) { function isValidPhoneE164(s) { return /^\+\d{8,15}$/.test(String(s || "").trim()); } -function hasValidWaAddress(s) { - return /^whatsapp:\+\d{8,15}$/.test(String(s || "").trim()); -} function canForwardToHuman() { return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && isValidPhoneE164(HUMAN_FORWARD_TO); @@ -261,7 +257,6 @@ function goBack(session) { else if (session.step === 5) session.step = 4; else if (session.step === 6) session.step = 5; else if (session.step === 7) session.step = 6; - else if (session.step === 8) session.step = 7; else session.step = Math.max(1, session.step - 1); } @@ -278,8 +273,7 @@ function promptForStep(vr, session) { "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); return; - case 7: gatherSpeech(vr, t("step7_whatsapp_number.main")); return; - case 8: gatherSpeech(vr, t("step8_summary_confirm.main", { + case 7: gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", dateLabel: session.dateISO || "", time: session.time24 || "", @@ -325,17 +319,6 @@ function parseYesNo(speech) { return null; } -function parseWhatsAppNumber(speech) { - const digits = String(speech || "").replace(/[^\d+]/g, ""); - if (hasValidWaAddress(digits)) return digits; - if (isValidPhoneE164(digits)) return `whatsapp:${digits}`; - const justNumbers = String(speech || "").replace(/[^\d]/g, ""); - if (justNumbers.length >= 8 && justNumbers.length <= 15) { - return `whatsapp:+${justNumbers}`; - } - return null; -} - // ---- Date parser function parseDateIT(speech) { const tt = normalizeText(speech).replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); @@ -569,23 +552,7 @@ app.post("/voice", async (req, res) => { } resetRetries(session); session.step = 7; - gatherSpeech(vr, t("step7_whatsapp_number.main")); - break; - } - - case 7: { - if (emptySpeech) { - gatherSpeech(vr, t("step7_whatsapp_number.error")); - break; - } - const waTo = parseWhatsAppNumber(speech); - if (!waTo) { - gatherSpeech(vr, t("step7_whatsapp_number.error")); - break; - } - session.waTo = waTo; - resetRetries(session); - session.step = 8; + session.waTo = null; gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", dateLabel: session.dateISO || "", @@ -595,7 +562,7 @@ app.post("/voice", async (req, res) => { break; } - case 8: { + case 7: { const confirmation = parseYesNo(speech); if (confirmation === null) { gatherSpeech(vr, t("step8_summary_confirm.short", { @@ -611,7 +578,11 @@ app.post("/voice", async (req, res) => { promptForStep(vr, session); break; } - await createCalendarEvent(session); + const calendarEvent = await createCalendarEvent(session); + if (!calendarEvent && canForwardToHuman()) { + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(forwardToHumanTwiml()); + } resetRetries(session); session.step = 1; gatherSpeech(vr, t("step9_success.main")); From 8f4b9e570a9ddadf531fccc0aee8b28f686e3547 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 17:22:26 +0100 Subject: [PATCH 050/143] Implement cancel command handling Add cancel command detection and related functionality. --- app.js | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index a1922ca6b6..4db305c3e1 100644 --- a/app.js +++ b/app.js @@ -49,6 +49,7 @@ const twilioClient = const YES_WORDS = ["si", "sì", "certo", "confermo", "ok", "va bene", "perfetto", "esatto"]; const NO_WORDS = ["no", "non", "annulla", "cancella", "negativo"]; +const CANCEL_WORDS = ["annulla", "annullare", "cancella", "modifica"]; // ======================= OPENING HOURS ======================= const OPENING = { @@ -205,6 +206,7 @@ function getSession(callSid) { waTo: null, tableDisplayId: null, tableLocks: [], + calendarEventId: null, tableNotes: null, durationMinutes: null, bookingType: "restaurant", @@ -240,13 +242,17 @@ function isBackCommand(speech) { tt.includes("indietro") || tt.includes("torna indietro") || tt.includes("tornare indietro") || - tt.includes("modifica") || tt.includes("errore") || tt.includes("ho sbagliato") || tt.includes("sbagliato") ); } +function isCancelCommand(speech) { + const tt = normalizeText(speech); + return CANCEL_WORDS.some((word) => tt.includes(word)); +} + function goBack(session) { if (!session || typeof session.step !== "number") return; if (session.step <= 1) return; @@ -387,12 +393,159 @@ function buildCalendarClient() { return google.calendar({ version: "v3", auth }); } +function getTimeRangeForDate(dateISO) { + const start = new Date(`${dateISO}T00:00:00`); + const end = new Date(`${dateISO}T23:59:59`); + return { start, end }; +} + +function isDateClosedByCalendar(events) { + return events.some((event) => { + const summary = String(event.summary || "").toLowerCase(); + const description = String(event.description || "").toLowerCase(); + return summary.includes("locale chiuso") || description.includes("locale chiuso"); + }); +} + +function extractTablesFromEvent(event) { + const description = String(event.description || ""); + const match = description.match(/Tavolo:\s*([^\n]+)/i); + if (!match) return []; + return match[1] + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function getEventTimeRange(event) { + const start = event.start?.dateTime || event.start?.date; + const end = event.end?.dateTime || event.end?.date; + if (!start || !end) return null; + return { + start: new Date(start), + end: new Date(end), + }; +} + +function overlapsRange(a, b) { + return a.start < b.end && b.start < a.end; +} + +function expandTableLocks(tableId) { + const combo = TABLE_COMBINATIONS.find((c) => c.displayId === tableId); + if (combo) return combo.replaces; + return [tableId]; +} + +function buildAvailableTables(occupied) { + return TABLES.filter((table) => !occupied.has(table.id)); +} + +function pickTableForParty(people, occupied) { + const availableTables = buildAvailableTables(occupied); + const direct = availableTables.find((table) => people >= table.min && people <= table.max); + if (direct) return { displayId: direct.id, locks: [direct.id], notes: direct.notes || null }; + + for (const combo of TABLE_COMBINATIONS) { + if (people < combo.min || people > combo.max) continue; + const unavailable = combo.replaces.some((id) => occupied.has(id)); + if (!unavailable) { + return { displayId: combo.displayId, locks: combo.replaces, notes: combo.notes || null }; + } + } + return null; +} + +async function listCalendarEvents(dateISO) { + if (!GOOGLE_CALENDAR_ID) return []; + const calendar = buildCalendarClient(); + if (!calendar) return []; + const { start, end } = getTimeRangeForDate(dateISO); + try { + const result = await calendar.events.list({ + calendarId: GOOGLE_CALENDAR_ID, + timeMin: start.toISOString(), + timeMax: end.toISOString(), + singleEvents: true, + orderBy: "startTime", + }); + return result?.data?.items || []; + } catch (err) { + console.error("[GOOGLE] Calendar list failed:", err); + return []; + } +} + +async function reserveTableForSession(session) { + const events = await listCalendarEvents(session.dateISO); + if (isDateClosedByCalendar(events)) { + return { status: "closed" }; + } + + const bookingStart = new Date(`${session.dateISO}T${session.time24}:00`); + const bookingEnd = new Date(bookingStart.getTime() + 120 * 60 * 1000); + const bookingRange = { start: bookingStart, end: bookingEnd }; + const occupied = new Set(); + + for (const event of events) { + const summary = String(event.summary || "").toLowerCase(); + if (summary.startsWith("annullata")) continue; + const eventRange = getEventTimeRange(event); + if (!eventRange || !overlapsRange(bookingRange, eventRange)) continue; + const tableIds = extractTablesFromEvent(event); + tableIds.flatMap(expandTableLocks).forEach((id) => occupied.add(id)); + } + + const selection = pickTableForParty(session.people, occupied); + if (!selection) return { status: "unavailable" }; + session.tableDisplayId = selection.displayId; + session.tableLocks = selection.locks; + session.tableNotes = selection.notes; + return { status: "ok", selection }; +} + +async function cancelCalendarEvent(session) { + if (!session?.calendarEventId) return null; + const calendar = buildCalendarClient(); + if (!calendar) return null; + const summary = session.calendarEventSummary || ""; + const updatedSummary = summary.startsWith("Annullata") + ? summary + : `Annullata - ${summary}`; + + try { + const result = await calendar.events.patch({ + calendarId: GOOGLE_CALENDAR_ID, + eventId: session.calendarEventId, + requestBody: { + summary: updatedSummary, + description: [ + `Nome: ${session.name || ""}`, + `Persone: ${session.people || ""}`, + `Note: ${session.specialRequestsRaw || "nessuna"}`, + `Preordine: ${session.preorderLabel || "nessuno"}`, + "Tavolo: ", + `Telefono: ${session.phone || "non fornito"}`, + ].join("\n"), + }, + }); + return result?.data || null; + } catch (err) { + console.error("[GOOGLE] Calendar cancel update failed:", err); + return null; + } +} + async function createCalendarEvent(session) { if (!GOOGLE_CALENDAR_ID) return null; const calendar = buildCalendarClient(); if (!calendar) return null; if (!session?.dateISO || !session?.time24 || !session?.name || !session?.people) return null; + const reservation = await reserveTableForSession(session); + if (reservation.status === "closed") return { status: "closed" }; + if (reservation.status === "unavailable") return { status: "unavailable" }; + const startDateTime = `${session.dateISO}T${session.time24}:00`; const endDate = new Date(`${session.dateISO}T${session.time24}:00`); endDate.setMinutes(endDate.getMinutes() + 120); @@ -409,7 +562,8 @@ async function createCalendarEvent(session) { `Persone: ${session.people}`, `Note: ${session.specialRequestsRaw || "nessuna"}`, `Preordine: ${session.preorderLabel || "nessuno"}`, - `WhatsApp: ${session.waTo || "non fornito"}`, + `Tavolo: ${session.tableDisplayId || "da assegnare"}`, + `Telefono: ${session.phone || "non fornito"}`, ].join("\n"), start: { dateTime: startDateTime, timeZone: GOOGLE_CALENDAR_TZ }, end: { dateTime: endDateTime, timeZone: GOOGLE_CALENDAR_TZ }, @@ -420,7 +574,12 @@ async function createCalendarEvent(session) { calendarId: GOOGLE_CALENDAR_ID, requestBody: event, }); - return result?.data || null; + const data = result?.data || null; + if (data?.id) { + session.calendarEventId = data.id; + session.calendarEventSummary = data.summary || event.summary; + } + return data; } catch (err) { console.error("[GOOGLE] Calendar insert failed:", err); return null; @@ -445,6 +604,22 @@ app.post("/voice", async (req, res) => { const emptySpeech = !normalizeText(speech); + if (!emptySpeech && isCancelCommand(speech)) { + const canceled = await cancelCalendarEvent(session); + if (canceled) { + session.step = 1; + gatherSpeech(vr, "Ho annullato la prenotazione. Vuoi prenotare di nuovo?"); + } else if (canForwardToHuman()) { + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(forwardToHumanTwiml()); + } else { + session.step = 1; + gatherSpeech(vr, "Ok, mi occupo di annullare la prenotazione. Vuoi fare una nuova richiesta?"); + } + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); + } + if (!emptySpeech && isBackCommand(speech)) { resetRetries(session); goBack(session); @@ -460,6 +635,7 @@ app.post("/voice", async (req, res) => { break; } session.name = speech.trim().slice(0, 60); + session.phone = req.body.From || session.phone; resetRetries(session); session.step = 2; gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); @@ -579,6 +755,16 @@ app.post("/voice", async (req, res) => { break; } const calendarEvent = await createCalendarEvent(session); + if (calendarEvent?.status === "closed") { + session.step = 2; + gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); + break; + } + if (calendarEvent?.status === "unavailable") { + session.step = 3; + gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); + break; + } if (!calendarEvent && canForwardToHuman()) { res.set("Content-Type", "text/xml; charset=utf-8"); return res.send(forwardToHumanTwiml()); From fca9700393e3b837dfa3c55234c3ff3ecb6857e7 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 17:45:17 +0100 Subject: [PATCH 051/143] Update app.js --- app.js | 139 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 119 insertions(+), 20 deletions(-) diff --git a/app.js b/app.js index 4db305c3e1..f355f9f9d2 100644 --- a/app.js +++ b/app.js @@ -195,6 +195,7 @@ function getSession(callSid) { retries: 0, name: null, dateISO: null, + dateLabel: null, time24: null, people: null, specialRequestsRaw: null, @@ -235,6 +236,34 @@ function toISODate(d) { return `${y}-${m}-${day}`; } +function formatDateLabel(dateISO) { + const date = new Date(`${dateISO}T00:00:00`); + const weekdays = [ + "domenica", + "lunedì", + "martedì", + "mercoledì", + "giovedì", + "venerdì", + "sabato", + ]; + const months = [ + "gennaio", + "febbraio", + "marzo", + "aprile", + "maggio", + "giugno", + "luglio", + "agosto", + "settembre", + "ottobre", + "novembre", + "dicembre", + ]; + return `${weekdays[date.getDay()]} ${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`; +} + // ---- Back/edit commands function isBackCommand(speech) { const tt = normalizeText(speech); @@ -270,7 +299,7 @@ function promptForStep(vr, session) { switch (session.step) { case 1: gatherSpeech(vr, t("step1_welcome_name.main")); return; case 2: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; - case 3: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO || "" })); return; + case 3: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); return; case 4: gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); return; case 5: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; case 6: @@ -281,7 +310,7 @@ function promptForStep(vr, session) { return; case 7: gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", - dateLabel: session.dateISO || "", + dateLabel: session.dateLabel || "", time: session.time24 || "", partySize: session.people || "", })); return; @@ -410,8 +439,11 @@ function isDateClosedByCalendar(events) { function extractTablesFromEvent(event) { const description = String(event.description || ""); const match = description.match(/Tavolo:\s*([^\n]+)/i); - if (!match) return []; - return match[1] + const summary = String(event.summary || ""); + const summaryMatch = summary.match(/tav(?:olo)?\s*([^\-,]+)/i); + const tableText = match?.[1] || summaryMatch?.[1]; + if (!tableText) return []; + return tableText .split(",") .map((entry) => entry.trim()) .filter(Boolean); @@ -437,18 +469,27 @@ function expandTableLocks(tableId) { return [tableId]; } -function buildAvailableTables(occupied) { - return TABLES.filter((table) => !occupied.has(table.id)); +function buildAvailableTables(occupied, availableOverride) { + return TABLES.filter((table) => { + if (occupied.has(table.id)) return false; + if (availableOverride && !availableOverride.has(table.id)) return false; + return true; + }); +} + +function buildAvailableTableSet(availableTables) { + return new Set(availableTables.map((table) => table.id)); } -function pickTableForParty(people, occupied) { - const availableTables = buildAvailableTables(occupied); +function pickTableForParty(people, occupied, availableOverride) { + const availableTables = buildAvailableTables(occupied, availableOverride); + const availableSet = buildAvailableTableSet(availableTables); const direct = availableTables.find((table) => people >= table.min && people <= table.max); if (direct) return { displayId: direct.id, locks: [direct.id], notes: direct.notes || null }; for (const combo of TABLE_COMBINATIONS) { if (people < combo.min || people > combo.max) continue; - const unavailable = combo.replaces.some((id) => occupied.has(id)); + const unavailable = combo.replaces.some((id) => occupied.has(id) || !availableSet.has(id)); if (!unavailable) { return { displayId: combo.displayId, locks: combo.replaces, notes: combo.notes || null }; } @@ -476,7 +517,7 @@ async function listCalendarEvents(dateISO) { } } -async function reserveTableForSession(session) { +async function reserveTableForSession(session, { commit } = { commit: false }) { const events = await listCalendarEvents(session.dateISO); if (isDateClosedByCalendar(events)) { return { status: "closed" }; @@ -486,21 +527,63 @@ async function reserveTableForSession(session) { const bookingEnd = new Date(bookingStart.getTime() + 120 * 60 * 1000); const bookingRange = { start: bookingStart, end: bookingEnd }; const occupied = new Set(); + const eventoEvents = []; for (const event of events) { const summary = String(event.summary || "").toLowerCase(); if (summary.startsWith("annullata")) continue; + if (summary.includes("evento")) { + eventoEvents.push(event); + continue; + } const eventRange = getEventTimeRange(event); if (!eventRange || !overlapsRange(bookingRange, eventRange)) continue; const tableIds = extractTablesFromEvent(event); tableIds.flatMap(expandTableLocks).forEach((id) => occupied.add(id)); } - const selection = pickTableForParty(session.people, occupied); + let availableOverride = null; + if (eventoEvents.length > 0) { + const eventTables = eventoEvents + .flatMap((event) => extractTablesFromEvent(event)) + .flatMap((id) => expandTableLocks(id)); + availableOverride = new Set(eventTables); + } + + const selection = pickTableForParty(session.people, occupied, availableOverride); if (!selection) return { status: "unavailable" }; session.tableDisplayId = selection.displayId; session.tableLocks = selection.locks; session.tableNotes = selection.notes; + + if (commit && eventoEvents.length > 0) { + const calendar = buildCalendarClient(); + if (calendar) { + for (const event of eventoEvents) { + const eventTables = extractTablesFromEvent(event); + const remaining = eventTables.filter((id) => !selection.locks.includes(id)); + const availableCount = remaining.length; + const updatedSummary = `Evento - tavoli disponibili: ${availableCount}`; + const baseDescription = String(event.description || ""); + const updatedDescription = baseDescription.match(/Tavolo:\s*/i) + ? baseDescription.replace(/Tavolo:\s*[^\n]*/i, `Tavolo: ${remaining.join(", ")}`) + : `${baseDescription}\nTavolo: ${remaining.join(", ")}`.trim(); + try { + await calendar.events.patch({ + calendarId: GOOGLE_CALENDAR_ID, + eventId: event.id, + requestBody: { + summary: updatedSummary, + description: updatedDescription, + }, + }); + } catch (err) { + console.error("[GOOGLE] Calendar event update failed:", err); + } + } + } + } + return { status: "ok", selection }; } @@ -509,9 +592,10 @@ async function cancelCalendarEvent(session) { const calendar = buildCalendarClient(); if (!calendar) return null; const summary = session.calendarEventSummary || ""; - const updatedSummary = summary.startsWith("Annullata") - ? summary - : `Annullata - ${summary}`; + const summaryWithoutTable = summary.replace(/,\s*tav[^,]+/i, "").trim(); + const updatedSummary = summaryWithoutTable.startsWith("Annullata") + ? summaryWithoutTable + : `Annullata - ${summaryWithoutTable}`; try { const result = await calendar.events.patch({ @@ -542,7 +626,7 @@ async function createCalendarEvent(session) { if (!calendar) return null; if (!session?.dateISO || !session?.time24 || !session?.name || !session?.people) return null; - const reservation = await reserveTableForSession(session); + const reservation = await reserveTableForSession(session, { commit: true }); if (reservation.status === "closed") return { status: "closed" }; if (reservation.status === "unavailable") return { status: "unavailable" }; @@ -555,14 +639,17 @@ async function createCalendarEvent(session) { endDate.getMinutes() ).padStart(2, "0")}:00`; + const tableLabel = session.tableLocks?.length + ? session.tableLocks.join(" e ") + : session.tableDisplayId || "da assegnare"; const event = { - summary: `Prenotazione ${session.name} (${session.people} pax)`, + summary: `Ore ${session.time24}, tav ${tableLabel}, ${session.name}, ${session.people} pax`, description: [ `Nome: ${session.name}`, `Persone: ${session.people}`, `Note: ${session.specialRequestsRaw || "nessuna"}`, `Preordine: ${session.preorderLabel || "nessuno"}`, - `Tavolo: ${session.tableDisplayId || "da assegnare"}`, + `Tavolo: ${tableLabel}`, `Telefono: ${session.phone || "non fornito"}`, ].join("\n"), start: { dateTime: startDateTime, timeZone: GOOGLE_CALENDAR_TZ }, @@ -655,7 +742,8 @@ app.post("/voice", async (req, res) => { session.dateISO = dateISO; resetRetries(session); session.step = 3; - gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateISO })); + session.dateLabel = formatDateLabel(dateISO); + gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel })); break; } @@ -726,12 +814,23 @@ app.post("/voice", async (req, res) => { break; } } + const availability = await reserveTableForSession(session, { commit: false }); + if (availability.status === "closed") { + session.step = 2; + gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); + break; + } + if (availability.status === "unavailable") { + session.step = 3; + gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); + break; + } resetRetries(session); session.step = 7; session.waTo = null; gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", - dateLabel: session.dateISO || "", + dateLabel: session.dateLabel || "", time: session.time24 || "", partySize: session.people || "", })); @@ -742,7 +841,7 @@ app.post("/voice", async (req, res) => { const confirmation = parseYesNo(speech); if (confirmation === null) { gatherSpeech(vr, t("step8_summary_confirm.short", { - dateLabel: session.dateISO || "", + dateLabel: session.dateLabel || "", time: session.time24 || "", partySize: session.people || "", })); From dad379ad14b4b852489822561597b04b22fffbac Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 18:04:14 +0100 Subject: [PATCH 052/143] Update app.js --- app.js | 213 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 184 insertions(+), 29 deletions(-) diff --git a/app.js b/app.js index f355f9f9d2..3e3aa38ab0 100644 --- a/app.js +++ b/app.js @@ -32,7 +32,6 @@ const BASE_URL = process.env.BASE_URL || ""; const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; - const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; const GOOGLE_CALENDAR_TZ = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; const GOOGLE_SERVICE_ACCOUNT_JSON_B64 = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64 || ""; @@ -154,6 +153,7 @@ function buildTwiml() { } function sayIt(response, text) { + // ESCAPE per evitare XML rotto (’ ecc) response.say({ language: "it-IT" }, xmlEscape(text)); } @@ -166,13 +166,13 @@ function gatherSpeech(response, promptText) { action: actionUrl, method: "POST", }); + // Anche qui escape gather.say({ language: "it-IT" }, xmlEscape(promptText)); } function isValidPhoneE164(s) { return /^\+\d{8,15}$/.test(String(s || "").trim()); } - function canForwardToHuman() { return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && isValidPhoneE164(HUMAN_FORWARD_TO); } @@ -194,6 +194,7 @@ function getSession(callSid) { step: 1, retries: 0, name: null, + phoneRequested: false, dateISO: null, dateLabel: null, time24: null, @@ -292,23 +293,25 @@ function goBack(session) { else if (session.step === 5) session.step = 4; else if (session.step === 6) session.step = 5; else if (session.step === 7) session.step = 6; + else if (session.step === 8) session.step = 7; else session.step = Math.max(1, session.step - 1); } function promptForStep(vr, session) { switch (session.step) { case 1: gatherSpeech(vr, t("step1_welcome_name.main")); return; - case 2: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; - case 3: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); return; - case 4: gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); return; - case 5: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; - case 6: + case 2: gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono?"); return; + case 3: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; + case 4: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); return; + case 5: gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); return; + case 6: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; + case 7: gatherSpeech( vr, "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); return; - case 7: gatherSpeech(vr, t("step8_summary_confirm.main", { + case 8: gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", dateLabel: session.dateLabel || "", time: session.time24 || "", @@ -318,7 +321,7 @@ function promptForStep(vr, session) { } } -// ====== parsing basilari ====== +// ====== parsing basilari (per arrivare al punto: FIX crash step 5) ====== function parseTimeIT(speech) { const tt = normalizeText(speech); const hm = tt.match(/(\d{1,2})[:\s](\d{2})/); @@ -354,7 +357,15 @@ function parseYesNo(speech) { return null; } -// ---- Date parser +function parsePhoneNumber(speech) { + if (!speech) return null; + if (isValidPhoneE164(speech)) return speech.trim(); + const digits = String(speech).replace(/[^\d]/g, ""); + if (digits.length >= 8 && digits.length <= 15) return `+${digits}`; + return null; +} + +// ---- Date: user-friendly (stesso parser robusto, qui versione breve per stabilità) function parseDateIT(speech) { const tt = normalizeText(speech).replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); const today = new Date(); @@ -392,7 +403,6 @@ function parseDateIT(speech) { return null; } -// ---- Google Calendar helpers function getGoogleCredentials() { if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { try { @@ -444,8 +454,8 @@ function extractTablesFromEvent(event) { const tableText = match?.[1] || summaryMatch?.[1]; if (!tableText) return []; return tableText - .split(",") - .map((entry) => entry.trim()) + .split(/,| e /i) + .map((entry) => normalizeTableId(entry)) .filter(Boolean); } @@ -463,6 +473,15 @@ function overlapsRange(a, b) { return a.start < b.end && b.start < a.end; } +function normalizeTableId(raw) { + const cleaned = String(raw || "") + .replace(/tav(?:olo)?/i, "") + .replace(/[^\dA-Z]/gi, "") + .toUpperCase(); + if (!cleaned) return null; + return cleaned.startsWith("T") ? cleaned : `T${cleaned}`; +} + function expandTableLocks(tableId) { const combo = TABLE_COMBINATIONS.find((c) => c.displayId === tableId); if (combo) return combo.replaces; @@ -497,6 +516,12 @@ function pickTableForParty(people, occupied, availableOverride) { return null; } +function getNextDateISO(dateISO) { + const date = new Date(`${dateISO}T00:00:00`); + date.setDate(date.getDate() + 1); + return toISODate(date); +} + async function listCalendarEvents(dateISO) { if (!GOOGLE_CALENDAR_ID) return []; const calendar = buildCalendarClient(); @@ -517,6 +542,103 @@ async function listCalendarEvents(dateISO) { } } +function formatTimeSlot(date, startDate) { + if (!date) return ""; + if ( + startDate && + date.getHours() === 0 && + date.getMinutes() === 0 && + date.getTime() > startDate.getTime() + ) { + return "24.00"; + } + const hh = String(date.getHours()).padStart(2, "0"); + const mm = String(date.getMinutes()).padStart(2, "0"); + return `${hh}.${mm}`; +} + +function buildAvailabilityDescription(dateISO, events) { + const tableIds = TABLES.map((table) => table.id); + const occupancy = new Map(tableIds.map((id) => [id, []])); + const bookingRange = { + start: new Date(`${dateISO}T00:00:00`), + end: new Date(`${dateISO}T23:59:59`), + }; + + for (const event of events) { + const summary = String(event.summary || "").toLowerCase(); + if (summary.startsWith("annullata")) continue; + if (summary.includes("tavoli disponibili")) continue; + if (summary.includes("locale chiuso")) continue; + if (summary.includes("evento")) continue; + const eventRange = getEventTimeRange(event); + if (!eventRange || !overlapsRange(bookingRange, eventRange)) continue; + const tableIdsForEvent = extractTablesFromEvent(event) + .flatMap(expandTableLocks) + .filter((id) => occupancy.has(id)); + for (const tableId of tableIdsForEvent) { + occupancy.get(tableId).push({ + start: eventRange.start, + end: eventRange.end, + }); + } + } + + const lines = tableIds.map((tableId) => { + const slots = occupancy.get(tableId) || []; + slots.sort((a, b) => a.start - b.start); + if (slots.length === 0) { + return `${tableId}:`; + } + const slotText = slots + .map((slot) => { + const start = formatTimeSlot(slot.start); + const end = formatTimeSlot(slot.end, slot.start); + return `occupato dalle ${start} alle ${end};`; + }) + .join(" "); + return `${tableId}: ${slotText}`; + }); + + return lines.join("\n"); +} + +async function upsertAvailabilityEvent(dateISO) { + if (!GOOGLE_CALENDAR_ID) return null; + const calendar = buildCalendarClient(); + if (!calendar) return null; + const events = await listCalendarEvents(dateISO); + const availabilityEvent = events.find((event) => + String(event.summary || "").toLowerCase().includes("tavoli disponibili") + ); + const description = buildAvailabilityDescription(dateISO, events); + const requestBody = { + summary: "Tavoli disponibili", + description, + start: { date: dateISO, timeZone: GOOGLE_CALENDAR_TZ }, + end: { date: getNextDateISO(dateISO), timeZone: GOOGLE_CALENDAR_TZ }, + }; + + try { + if (availabilityEvent?.id) { + const result = await calendar.events.patch({ + calendarId: GOOGLE_CALENDAR_ID, + eventId: availabilityEvent.id, + requestBody, + }); + return result?.data || null; + } + const result = await calendar.events.insert({ + calendarId: GOOGLE_CALENDAR_ID, + requestBody, + }); + return result?.data || null; + } catch (err) { + console.error("[GOOGLE] Availability event update failed:", err); + return null; + } +} + async function reserveTableForSession(session, { commit } = { commit: false }) { const events = await listCalendarEvents(session.dateISO); if (isDateClosedByCalendar(events)) { @@ -613,6 +735,9 @@ async function cancelCalendarEvent(session) { ].join("\n"), }, }); + if (session.dateISO) { + await upsertAvailabilityEvent(session.dateISO); + } return result?.data || null; } catch (err) { console.error("[GOOGLE] Calendar cancel update failed:", err); @@ -666,6 +791,7 @@ async function createCalendarEvent(session) { session.calendarEventId = data.id; session.calendarEventSummary = data.summary || event.summary; } + await upsertAvailabilityEvent(session.dateISO); return data; } catch (err) { console.error("[GOOGLE] Calendar insert failed:", err); @@ -722,14 +848,30 @@ app.post("/voice", async (req, res) => { break; } session.name = speech.trim().slice(0, 60); - session.phone = req.body.From || session.phone; resetRetries(session); session.step = 2; - gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); + gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono?"); break; } case 2: { + if (emptySpeech) { + gatherSpeech(vr, "Scusami, non ho sentito il numero. Me lo ripeti?"); + break; + } + const phone = parsePhoneNumber(speech); + if (!phone) { + gatherSpeech(vr, "Scusami, non ho capito il numero. Puoi ripeterlo?"); + break; + } + session.phone = phone; + resetRetries(session); + session.step = 3; + gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); + break; + } + + case 3: { if (emptySpeech) { gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; @@ -741,13 +883,13 @@ app.post("/voice", async (req, res) => { } session.dateISO = dateISO; resetRetries(session); - session.step = 3; + session.step = 4; session.dateLabel = formatDateLabel(dateISO); gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel })); break; } - case 3: { + case 4: { if (emptySpeech) { gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); break; @@ -759,12 +901,12 @@ app.post("/voice", async (req, res) => { } session.time24 = time24; resetRetries(session); - session.step = 4; + session.step = 5; gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 })); break; } - case 4: { + case 5: { if (emptySpeech) { gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; @@ -776,15 +918,15 @@ app.post("/voice", async (req, res) => { } session.people = people; resetRetries(session); - session.step = 5; + session.step = 6; gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); break; } - case 5: { + case 6: { session.specialRequestsRaw = emptySpeech ? "nessuna" : speech.trim().slice(0, 200); resetRetries(session); - session.step = 6; + session.step = 7; gatherSpeech( vr, "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." @@ -792,7 +934,7 @@ app.post("/voice", async (req, res) => { break; } - case 6: { + case 7: { if (emptySpeech) { promptForStep(vr, session); break; @@ -814,19 +956,24 @@ app.post("/voice", async (req, res) => { break; } } + if (!session.phone) { + session.step = 2; + gatherSpeech(vr, "Mi manca il numero di telefono. Me lo dici?"); + break; + } const availability = await reserveTableForSession(session, { commit: false }); if (availability.status === "closed") { - session.step = 2; + session.step = 3; gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); break; } if (availability.status === "unavailable") { - session.step = 3; + session.step = 4; gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); break; } resetRetries(session); - session.step = 7; + session.step = 8; session.waTo = null; gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", @@ -837,7 +984,12 @@ app.post("/voice", async (req, res) => { break; } - case 7: { + case 8: { + if (!session.phone) { + session.step = 2; + gatherSpeech(vr, "Prima di confermare, mi serve il numero di telefono."); + break; + } const confirmation = parseYesNo(speech); if (confirmation === null) { gatherSpeech(vr, t("step8_summary_confirm.short", { @@ -855,12 +1007,12 @@ app.post("/voice", async (req, res) => { } const calendarEvent = await createCalendarEvent(session); if (calendarEvent?.status === "closed") { - session.step = 2; + session.step = 3; gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); break; } if (calendarEvent?.status === "unavailable") { - session.step = 3; + session.step = 4; gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); break; } @@ -900,6 +1052,9 @@ app.post("/voice", async (req, res) => { } }); +// NOTA: /finalize lo rimettiamo dopo, quando confermi che non cade più allo step 5. +// (non serve per risolvere il crash delle intolleranze) + app.get("/", (req, res) => { res.send("TuttiBrilli Voice Booking is running. Use POST /voice from Twilio."); }); From f5c02a64f59f9079970082209ad4601c339e34db Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 18:15:08 +0100 Subject: [PATCH 053/143] Refactor session steps and update prompts --- app.js | 101 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 56 insertions(+), 45 deletions(-) diff --git a/app.js b/app.js index 3e3aa38ab0..c6eb0cb70c 100644 --- a/app.js +++ b/app.js @@ -294,14 +294,15 @@ function goBack(session) { else if (session.step === 6) session.step = 5; else if (session.step === 7) session.step = 6; else if (session.step === 8) session.step = 7; + else if (session.step === 9) session.step = 8; else session.step = Math.max(1, session.step - 1); } function promptForStep(vr, session) { switch (session.step) { case 1: gatherSpeech(vr, t("step1_welcome_name.main")); return; - case 2: gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono?"); return; - case 3: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; + case 2: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; + case 3: gatherSpeech(vr, "Perfetto. In quante persone siete?"); return; case 4: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); return; case 5: gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); return; case 6: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; @@ -311,7 +312,8 @@ function promptForStep(vr, session) { "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); return; - case 8: gatherSpeech(vr, t("step8_summary_confirm.main", { + case 8: gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); return; + case 9: gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", dateLabel: session.dateLabel || "", time: session.time24 || "", @@ -361,7 +363,10 @@ function parsePhoneNumber(speech) { if (!speech) return null; if (isValidPhoneE164(speech)) return speech.trim(); const digits = String(speech).replace(/[^\d]/g, ""); - if (digits.length >= 8 && digits.length <= 15) return `+${digits}`; + if (digits.length >= 8 && digits.length <= 15) { + if (digits.length <= 10) return `+39${digits}`; + return `+${digits}`; + } return null; } @@ -850,41 +855,50 @@ app.post("/voice", async (req, res) => { session.name = speech.trim().slice(0, 60); resetRetries(session); session.step = 2; - gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono?"); + gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); break; } case 2: { if (emptySpeech) { - gatherSpeech(vr, "Scusami, non ho sentito il numero. Me lo ripeti?"); + gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } - const phone = parsePhoneNumber(speech); - if (!phone) { - gatherSpeech(vr, "Scusami, non ho capito il numero. Puoi ripeterlo?"); + const dateISO = parseDateIT(speech); + if (!dateISO) { + gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); break; } - session.phone = phone; + session.dateISO = dateISO; resetRetries(session); session.step = 3; - gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); + session.dateLabel = formatDateLabel(dateISO); + gatherSpeech(vr, "Perfetto. In quante persone siete?"); break; } case 3: { if (emptySpeech) { - gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); + gatherSpeech(vr, t("step5_party_size_ask_notes.error")); break; } - const dateISO = parseDateIT(speech); - if (!dateISO) { - gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); + const people = parsePeopleIT(speech); + if (!people) { + gatherSpeech(vr, t("step5_party_size_ask_notes.error")); + break; + } + session.people = people; + const events = await listCalendarEvents(session.dateISO); + const date = new Date(`${session.dateISO}T00:00:00`); + const isHoliday = HOLIDAYS_SET.has(session.dateISO); + const isClosedDay = date.getDay() === OPENING.closedDay; + if (isHoliday || isClosedDay || isDateClosedByCalendar(events)) { + session.step = 2; + gatherSpeech(vr, "Mi dispiace, il locale risulta chiuso quel giorno. Vuoi scegliere un'altra data?"); break; } - session.dateISO = dateISO; resetRetries(session); session.step = 4; - session.dateLabel = formatDateLabel(dateISO); gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel })); break; } @@ -907,26 +921,9 @@ app.post("/voice", async (req, res) => { } case 5: { - if (emptySpeech) { - gatherSpeech(vr, t("step5_party_size_ask_notes.error")); - break; - } - const people = parsePeopleIT(speech); - if (!people) { - gatherSpeech(vr, t("step5_party_size_ask_notes.error")); - break; - } - session.people = people; - resetRetries(session); - session.step = 6; - gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); - break; - } - - case 6: { session.specialRequestsRaw = emptySpeech ? "nessuna" : speech.trim().slice(0, 200); resetRetries(session); - session.step = 7; + session.step = 6; gatherSpeech( vr, "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." @@ -934,7 +931,7 @@ app.post("/voice", async (req, res) => { break; } - case 7: { + case 6: { if (emptySpeech) { promptForStep(vr, session); break; @@ -956,14 +953,9 @@ app.post("/voice", async (req, res) => { break; } } - if (!session.phone) { - session.step = 2; - gatherSpeech(vr, "Mi manca il numero di telefono. Me lo dici?"); - break; - } const availability = await reserveTableForSession(session, { commit: false }); if (availability.status === "closed") { - session.step = 3; + session.step = 2; gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); break; } @@ -973,8 +965,27 @@ app.post("/voice", async (req, res) => { break; } resetRetries(session); - session.step = 8; + session.step = 7; session.waTo = null; + gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); + break; + } + + case 7: { + if (!session.phone) { + if (emptySpeech) { + gatherSpeech(vr, "Scusami, non ho sentito il numero. Me lo ripeti?"); + break; + } + const phone = parsePhoneNumber(speech); + if (!phone) { + gatherSpeech(vr, "Scusami, non ho capito il numero. Puoi ripeterlo?"); + break; + } + session.phone = phone; + } + resetRetries(session); + session.step = 8; gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", dateLabel: session.dateLabel || "", @@ -986,7 +997,7 @@ app.post("/voice", async (req, res) => { case 8: { if (!session.phone) { - session.step = 2; + session.step = 7; gatherSpeech(vr, "Prima di confermare, mi serve il numero di telefono."); break; } @@ -1007,7 +1018,7 @@ app.post("/voice", async (req, res) => { } const calendarEvent = await createCalendarEvent(session); if (calendarEvent?.status === "closed") { - session.step = 3; + session.step = 2; gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); break; } From 0166bec5a503bfc6db87a8420ea7c02d0efe6316 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sat, 3 Jan 2026 18:29:48 +0100 Subject: [PATCH 054/143] Update app.js --- app.js | 248 +++++++++++++++++++++++++++------------------------------ 1 file changed, 119 insertions(+), 129 deletions(-) diff --git a/app.js b/app.js index c6eb0cb70c..35ebe0094d 100644 --- a/app.js +++ b/app.js @@ -194,7 +194,6 @@ function getSession(callSid) { step: 1, retries: 0, name: null, - phoneRequested: false, dateISO: null, dateLabel: null, time24: null, @@ -208,6 +207,8 @@ function getSession(callSid) { waTo: null, tableDisplayId: null, tableLocks: [], + splitRequired: false, + outsideRequired: false, calendarEventId: null, tableNotes: null, durationMinutes: null, @@ -304,15 +305,22 @@ function promptForStep(vr, session) { case 2: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; case 3: gatherSpeech(vr, "Perfetto. In quante persone siete?"); return; case 4: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); return; - case 5: gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 || "" })); return; - case 6: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; - case 7: + case 5: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; + case 6: gatherSpeech( vr, "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." ); return; - case 8: gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); return; + case 7: + gatherSpeech( + vr, + "Non abbiamo più disponibilità per un unico tavolo. Posso sistemarvi in tavoli separati?" + ); + return; + case 8: + gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); + return; case 9: gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", dateLabel: session.dateLabel || "", @@ -501,6 +509,10 @@ function buildAvailableTables(occupied, availableOverride) { }); } +function getTableById(id) { + return TABLES.find((table) => table.id === id) || null; +} + function buildAvailableTableSet(availableTables) { return new Set(availableTables.map((table) => table.id)); } @@ -521,10 +533,25 @@ function pickTableForParty(people, occupied, availableOverride) { return null; } -function getNextDateISO(dateISO) { - const date = new Date(`${dateISO}T00:00:00`); - date.setDate(date.getDate() + 1); - return toISODate(date); +function pickSplitTables(people, availableTables) { + const sorted = [...availableTables].sort((a, b) => b.max - a.max); + const selected = []; + let remaining = people; + let capacity = 0; + + for (const table of sorted) { + if (remaining <= 0) break; + selected.push(table); + capacity += table.max; + remaining -= table.max; + } + + if (capacity < people || selected.length === 0) return null; + return { + displayIds: selected.map((table) => table.id), + locks: selected.map((table) => table.id), + notes: "tavoli separati", + }; } async function listCalendarEvents(dateISO) { @@ -547,103 +574,6 @@ async function listCalendarEvents(dateISO) { } } -function formatTimeSlot(date, startDate) { - if (!date) return ""; - if ( - startDate && - date.getHours() === 0 && - date.getMinutes() === 0 && - date.getTime() > startDate.getTime() - ) { - return "24.00"; - } - const hh = String(date.getHours()).padStart(2, "0"); - const mm = String(date.getMinutes()).padStart(2, "0"); - return `${hh}.${mm}`; -} - -function buildAvailabilityDescription(dateISO, events) { - const tableIds = TABLES.map((table) => table.id); - const occupancy = new Map(tableIds.map((id) => [id, []])); - const bookingRange = { - start: new Date(`${dateISO}T00:00:00`), - end: new Date(`${dateISO}T23:59:59`), - }; - - for (const event of events) { - const summary = String(event.summary || "").toLowerCase(); - if (summary.startsWith("annullata")) continue; - if (summary.includes("tavoli disponibili")) continue; - if (summary.includes("locale chiuso")) continue; - if (summary.includes("evento")) continue; - const eventRange = getEventTimeRange(event); - if (!eventRange || !overlapsRange(bookingRange, eventRange)) continue; - const tableIdsForEvent = extractTablesFromEvent(event) - .flatMap(expandTableLocks) - .filter((id) => occupancy.has(id)); - for (const tableId of tableIdsForEvent) { - occupancy.get(tableId).push({ - start: eventRange.start, - end: eventRange.end, - }); - } - } - - const lines = tableIds.map((tableId) => { - const slots = occupancy.get(tableId) || []; - slots.sort((a, b) => a.start - b.start); - if (slots.length === 0) { - return `${tableId}:`; - } - const slotText = slots - .map((slot) => { - const start = formatTimeSlot(slot.start); - const end = formatTimeSlot(slot.end, slot.start); - return `occupato dalle ${start} alle ${end};`; - }) - .join(" "); - return `${tableId}: ${slotText}`; - }); - - return lines.join("\n"); -} - -async function upsertAvailabilityEvent(dateISO) { - if (!GOOGLE_CALENDAR_ID) return null; - const calendar = buildCalendarClient(); - if (!calendar) return null; - const events = await listCalendarEvents(dateISO); - const availabilityEvent = events.find((event) => - String(event.summary || "").toLowerCase().includes("tavoli disponibili") - ); - const description = buildAvailabilityDescription(dateISO, events); - const requestBody = { - summary: "Tavoli disponibili", - description, - start: { date: dateISO, timeZone: GOOGLE_CALENDAR_TZ }, - end: { date: getNextDateISO(dateISO), timeZone: GOOGLE_CALENDAR_TZ }, - }; - - try { - if (availabilityEvent?.id) { - const result = await calendar.events.patch({ - calendarId: GOOGLE_CALENDAR_ID, - eventId: availabilityEvent.id, - requestBody, - }); - return result?.data || null; - } - const result = await calendar.events.insert({ - calendarId: GOOGLE_CALENDAR_ID, - requestBody, - }); - return result?.data || null; - } catch (err) { - console.error("[GOOGLE] Availability event update failed:", err); - return null; - } -} - async function reserveTableForSession(session, { commit } = { commit: false }) { const events = await listCalendarEvents(session.dateISO); if (isDateClosedByCalendar(events)) { @@ -678,10 +608,36 @@ async function reserveTableForSession(session, { commit } = { commit: false }) { } const selection = pickTableForParty(session.people, occupied, availableOverride); - if (!selection) return { status: "unavailable" }; + if (!selection) { + const availableTables = buildAvailableTables(occupied, availableOverride); + const insideTables = availableTables.filter((table) => table.area === "inside"); + const insideSplit = pickSplitTables(session.people, insideTables); + if (insideSplit) { + session.tableDisplayId = insideSplit.displayIds.join(" e "); + session.tableLocks = insideSplit.locks; + session.tableNotes = insideSplit.notes; + session.splitRequired = true; + session.outsideRequired = false; + return { status: "needs_split" }; + } + + const anySplit = pickSplitTables(session.people, availableTables); + if (anySplit) { + session.tableDisplayId = anySplit.displayIds.join(" e "); + session.tableLocks = anySplit.locks; + session.tableNotes = anySplit.notes; + session.splitRequired = true; + session.outsideRequired = anySplit.locks.some((id) => getTableById(id)?.area === "outside"); + return { status: "needs_outside" }; + } + + return { status: "unavailable" }; + } session.tableDisplayId = selection.displayId; session.tableLocks = selection.locks; session.tableNotes = selection.notes; + session.splitRequired = false; + session.outsideRequired = selection.locks.some((id) => getTableById(id)?.area === "outside"); if (commit && eventoEvents.length > 0) { const calendar = buildCalendarClient(); @@ -740,9 +696,6 @@ async function cancelCalendarEvent(session) { ].join("\n"), }, }); - if (session.dateISO) { - await upsertAvailabilityEvent(session.dateISO); - } return result?.data || null; } catch (err) { console.error("[GOOGLE] Calendar cancel update failed:", err); @@ -796,7 +749,6 @@ async function createCalendarEvent(session) { session.calendarEventId = data.id; session.calendarEventSummary = data.summary || event.summary; } - await upsertAvailabilityEvent(session.dateISO); return data; } catch (err) { console.error("[GOOGLE] Calendar insert failed:", err); @@ -916,7 +868,7 @@ app.post("/voice", async (req, res) => { session.time24 = time24; resetRetries(session); session.step = 5; - gatherSpeech(vr, t("step4_confirm_time_ask_party_size.main", { time: session.time24 })); + gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); break; } @@ -964,28 +916,71 @@ app.post("/voice", async (req, res) => { gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); break; } + if (availability.status === "needs_split" || availability.status === "needs_outside" || session.outsideRequired) { + session.step = 7; + if (session.outsideRequired) { + gatherSpeech( + vr, + "Abbiamo disponibilità solo all'esterno. La sala esterna è senza copertura e con maltempo non posso garantire un tavolo all'interno. Vuoi confermare?" + ); + } else { + gatherSpeech( + vr, + "Non abbiamo più disponibilità per un unico tavolo. Posso sistemarvi in tavoli separati?" + ); + } + break; + } resetRetries(session); - session.step = 7; - session.waTo = null; + session.step = 8; gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); break; } case 7: { - if (!session.phone) { - if (emptySpeech) { - gatherSpeech(vr, "Scusami, non ho sentito il numero. Me lo ripeti?"); - break; + const confirmation = parseYesNo(speech); + if (confirmation === null) { + if (session.outsideRequired) { + gatherSpeech( + vr, + "Ti ricordo che la sala esterna è senza copertura e con maltempo non posso garantire un tavolo all'interno. Confermi?" + ); + } else { + gatherSpeech( + vr, + "Posso sistemarvi in tavoli separati? Se preferisci, ti passo un operatore." + ); } - const phone = parsePhoneNumber(speech); - if (!phone) { - gatherSpeech(vr, "Scusami, non ho capito il numero. Puoi ripeterlo?"); - break; + break; + } + if (!confirmation) { + if (canForwardToHuman()) { + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(forwardToHumanTwiml()); } - session.phone = phone; + sayIt(vr, t("step9_fallback_transfer_operator.main")); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); } resetRetries(session); session.step = 8; + gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); + break; + } + + case 8: { + if (emptySpeech) { + gatherSpeech(vr, "Scusami, non ho sentito il numero. Me lo ripeti?"); + break; + } + const phone = parsePhoneNumber(speech); + if (!phone) { + gatherSpeech(vr, "Scusami, non ho capito il numero. Puoi ripeterlo?"); + break; + } + session.phone = phone; + resetRetries(session); + session.step = 9; gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", dateLabel: session.dateLabel || "", @@ -995,12 +990,7 @@ app.post("/voice", async (req, res) => { break; } - case 8: { - if (!session.phone) { - session.step = 7; - gatherSpeech(vr, "Prima di confermare, mi serve il numero di telefono."); - break; - } + case 9: { const confirmation = parseYesNo(speech); if (confirmation === null) { gatherSpeech(vr, t("step8_summary_confirm.short", { From 55b5e1089afae82a88c017d203a4ff0aca2f9968 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 09:57:24 +0100 Subject: [PATCH 055/143] Implement critical reservation handling and Twilio notifications Added support for critical reservations and notifications. --- app.js | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 160 insertions(+), 12 deletions(-) diff --git a/app.js b/app.js index 35ebe0094d..75e4e2f164 100644 --- a/app.js +++ b/app.js @@ -32,6 +32,7 @@ const BASE_URL = process.env.BASE_URL || ""; const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; +const TWILIO_VOICE_FROM = process.env.TWILIO_VOICE_FROM || ""; const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; const GOOGLE_CALENDAR_TZ = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; const GOOGLE_SERVICE_ACCOUNT_JSON_B64 = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64 || ""; @@ -39,6 +40,7 @@ const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || " const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "false").toLowerCase() === "true"; const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; +const CRITICAL_COLOR_ID = process.env.CRITICAL_COLOR_ID || "11"; const HOLIDAYS_YYYY_MM_DD = process.env.HOLIDAYS_YYYY_MM_DD || ""; const HOLIDAYS_SET = new Set(HOLIDAYS_YYYY_MM_DD.split(",").map((s) => s.trim()).filter(Boolean)); @@ -184,6 +186,22 @@ function forwardToHumanTwiml() { return vr.toString(); } +async function notifyCriticalReservation(phone, summary) { + if (!twilioClient) return null; + try { + return await twilioClient.calls.create({ + to: "+393881669661", + from: TWILIO_VOICE_FROM || HUMAN_FORWARD_TO, + twiml: new twilio.twiml.VoiceResponse() + .say({ language: "it-IT" }, xmlEscape(`Prenotazione con criticità. ${summary || ""}`)) + .toString(), + }); + } catch (err) { + console.error("[TWILIO] Critical notify failed:", err); + return null; + } +} + // ======================= SESSION ======================= const sessions = new Map(); @@ -209,6 +227,7 @@ function getSession(callSid) { tableLocks: [], splitRequired: false, outsideRequired: false, + criticalReservation: false, calendarEventId: null, tableNotes: null, durationMinutes: null, @@ -554,6 +573,12 @@ function pickSplitTables(people, availableTables) { }; } +function getNextDateISO(dateISO) { + const date = new Date(`${dateISO}T00:00:00`); + date.setDate(date.getDate() + 1); + return toISODate(date); +} + async function listCalendarEvents(dateISO) { if (!GOOGLE_CALENDAR_ID) return []; const calendar = buildCalendarClient(); @@ -574,6 +599,103 @@ async function listCalendarEvents(dateISO) { } } +function formatTimeSlot(date, startDate) { + if (!date) return ""; + if ( + startDate && + date.getHours() === 0 && + date.getMinutes() === 0 && + date.getTime() > startDate.getTime() + ) { + return "24.00"; + } + const hh = String(date.getHours()).padStart(2, "0"); + const mm = String(date.getMinutes()).padStart(2, "0"); + return `${hh}.${mm}`; +} + +function buildAvailabilityDescription(dateISO, events) { + const tableIds = TABLES.map((table) => table.id); + const occupancy = new Map(tableIds.map((id) => [id, []])); + const bookingRange = { + start: new Date(`${dateISO}T00:00:00`), + end: new Date(`${dateISO}T23:59:59`), + }; + + for (const event of events) { + const summary = String(event.summary || "").toLowerCase(); + if (summary.startsWith("annullata")) continue; + if (summary.includes("tavoli disponibili")) continue; + if (summary.includes("locale chiuso")) continue; + if (summary.includes("evento")) continue; + const eventRange = getEventTimeRange(event); + if (!eventRange || !overlapsRange(bookingRange, eventRange)) continue; + const tableIdsForEvent = extractTablesFromEvent(event) + .flatMap(expandTableLocks) + .filter((id) => occupancy.has(id)); + for (const tableId of tableIdsForEvent) { + occupancy.get(tableId).push({ + start: eventRange.start, + end: eventRange.end, + }); + } + } + + const lines = tableIds.map((tableId) => { + const slots = occupancy.get(tableId) || []; + slots.sort((a, b) => a.start - b.start); + if (slots.length === 0) { + return `${tableId}:`; + } + const slotText = slots + .map((slot) => { + const start = formatTimeSlot(slot.start); + const end = formatTimeSlot(slot.end, slot.start); + return `occupato dalle ${start} alle ${end};`; + }) + .join(" "); + return `${tableId}: ${slotText}`; + }); + + return lines.join("\n"); +} + +async function upsertAvailabilityEvent(dateISO) { + if (!GOOGLE_CALENDAR_ID) return null; + const calendar = buildCalendarClient(); + if (!calendar) return null; + const events = await listCalendarEvents(dateISO); + const availabilityEvent = events.find((event) => + String(event.summary || "").toLowerCase().includes("tavoli disponibili") + ); + const description = buildAvailabilityDescription(dateISO, events); + const requestBody = { + summary: "Tavoli disponibili", + description, + start: { date: dateISO, timeZone: GOOGLE_CALENDAR_TZ }, + end: { date: getNextDateISO(dateISO), timeZone: GOOGLE_CALENDAR_TZ }, + }; + + try { + if (availabilityEvent?.id) { + const result = await calendar.events.patch({ + calendarId: GOOGLE_CALENDAR_ID, + eventId: availabilityEvent.id, + requestBody, + }); + return result?.data || null; + } + const result = await calendar.events.insert({ + calendarId: GOOGLE_CALENDAR_ID, + requestBody, + }); + return result?.data || null; + } catch (err) { + console.error("[GOOGLE] Availability event update failed:", err); + return null; + } +} + async function reserveTableForSession(session, { commit } = { commit: false }) { const events = await listCalendarEvents(session.dateISO); if (isDateClosedByCalendar(events)) { @@ -696,6 +818,9 @@ async function cancelCalendarEvent(session) { ].join("\n"), }, }); + if (session.dateISO) { + await upsertAvailabilityEvent(session.dateISO); + } return result?.data || null; } catch (err) { console.error("[GOOGLE] Calendar cancel update failed:", err); @@ -738,6 +863,9 @@ async function createCalendarEvent(session) { start: { dateTime: startDateTime, timeZone: GOOGLE_CALENDAR_TZ }, end: { dateTime: endDateTime, timeZone: GOOGLE_CALENDAR_TZ }, }; + if (session.criticalReservation) { + event.colorId = CRITICAL_COLOR_ID; + } try { const result = await calendar.events.insert({ @@ -749,6 +877,7 @@ async function createCalendarEvent(session) { session.calendarEventId = data.id; session.calendarEventSummary = data.summary || event.summary; } + await upsertAvailabilityEvent(session.dateISO); return data; } catch (err) { console.error("[GOOGLE] Calendar insert failed:", err); @@ -917,18 +1046,12 @@ app.post("/voice", async (req, res) => { break; } if (availability.status === "needs_split" || availability.status === "needs_outside" || session.outsideRequired) { - session.step = 7; - if (session.outsideRequired) { - gatherSpeech( - vr, - "Abbiamo disponibilità solo all'esterno. La sala esterna è senza copertura e con maltempo non posso garantire un tavolo all'interno. Vuoi confermare?" - ); - } else { - gatherSpeech( - vr, - "Non abbiamo più disponibilità per un unico tavolo. Posso sistemarvi in tavoli separati?" - ); - } + session.criticalReservation = true; + session.step = 8; + gatherSpeech( + vr, + "La prenotazione è stata effettuata con criticità. Ti richiamerà un operatore. Intanto mi lasci un numero di telefono? Se è italiano, aggiungo io il +39." + ); break; } resetRetries(session); @@ -980,6 +1103,31 @@ app.post("/voice", async (req, res) => { } session.phone = phone; resetRetries(session); + if (session.criticalReservation) { + const calendarEvent = await createCalendarEvent(session); + const summary = `Data ${session.dateLabel || session.dateISO || ""}, ore ${session.time24 || ""}, ${session.people || ""} persone.`; + await notifyCriticalReservation(session.phone, summary); + if (calendarEvent?.status === "closed") { + session.step = 2; + session.criticalReservation = false; + gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); + break; + } + if (calendarEvent?.status === "unavailable") { + session.step = 4; + session.criticalReservation = false; + gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); + break; + } + resetRetries(session); + session.step = 1; + session.criticalReservation = false; + gatherSpeech( + vr, + "La prenotazione è stata effettuata. Verrai richiamato da un operatore per confermare i dettagli." + ); + break; + } session.step = 9; gatherSpeech(vr, t("step8_summary_confirm.main", { name: session.name || "", From b70370da588435e9bd88ad58ece576122dd6fe79 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 10:38:21 +0100 Subject: [PATCH 056/143] Add outbound call and TwiML routes --- app.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app.js b/app.js index 75e4e2f164..7a516f4130 100644 --- a/app.js +++ b/app.js @@ -888,6 +888,47 @@ async function createCalendarEvent(session) { // ======================= ROUTES ======================= app.get("/health", (req, res) => res.json({ ok: true })); +app.post("/call/outbound", async (req, res) => { + try { + const to = String(req.body?.to || "").trim(); + if (!to || !isValidPhoneE164(to)) { + return res.status(400).json({ ok: false, error: "Invalid 'to' number" }); + } + if (!twilioClient || !TWILIO_VOICE_FROM) { + return res.status(500).json({ ok: false, error: "Twilio not configured" }); + } + const twimlUrl = BASE_URL + ? `${BASE_URL}/twilio/voice/outbound` + : "/twilio/voice/outbound"; + const call = await twilioClient.calls.create({ + to, + from: TWILIO_VOICE_FROM, + url: twimlUrl, + method: "POST", + }); + return res.json({ ok: true, callSid: call.sid }); + } catch (err) { + console.error("[OUTBOUND] Error:", err); + return res.status(500).json({ ok: false }); + } +}); + +app.post("/twilio/voice/outbound", (req, res) => { + try { + const vr = buildTwiml(); + vr.say( + { language: "it-IT", voice: "alice" }, + xmlEscape("Ciao! Ti chiamiamo da TuttiBrilli per un aggiornamento sulla tua prenotazione. Grazie.") + ); + vr.hangup(); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); + } catch (err) { + console.error("[OUTBOUND_TWIML] Error:", err); + return res.status(500).send("Error"); + } +}); + app.post("/voice", async (req, res) => { const callSid = req.body.CallSid || ""; const speech = req.body.SpeechResult || ""; From c213e78702afac2831f57a42a8ac5107c109fda4 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 10:52:28 +0100 Subject: [PATCH 057/143] Update app.js --- app.js | 100 +++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 34 deletions(-) diff --git a/app.js b/app.js index 7a516f4130..1e69ebc675 100644 --- a/app.js +++ b/app.js @@ -179,6 +179,13 @@ function canForwardToHuman() { return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && isValidPhoneE164(HUMAN_FORWARD_TO); } +function requireTwilioVoiceFrom() { + if (!TWILIO_VOICE_FROM) { + throw new Error("TWILIO_VOICE_FROM is not set"); + } + return TWILIO_VOICE_FROM; +} + function forwardToHumanTwiml() { const vr = buildTwiml(); sayIt(vr, t("step9_fallback_transfer_operator.main")); @@ -189,9 +196,10 @@ function forwardToHumanTwiml() { async function notifyCriticalReservation(phone, summary) { if (!twilioClient) return null; try { + const fromNumber = requireTwilioVoiceFrom(); return await twilioClient.calls.create({ to: "+393881669661", - from: TWILIO_VOICE_FROM || HUMAN_FORWARD_TO, + from: fromNumber, twiml: new twilio.twiml.VoiceResponse() .say({ language: "it-IT" }, xmlEscape(`Prenotazione con criticità. ${summary || ""}`)) .toString(), @@ -320,11 +328,21 @@ function goBack(session) { function promptForStep(vr, session) { switch (session.step) { - case 1: gatherSpeech(vr, t("step1_welcome_name.main")); return; - case 2: gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); return; - case 3: gatherSpeech(vr, "Perfetto. In quante persone siete?"); return; - case 4: gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); return; - case 5: gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); return; + case 1: + gatherSpeech(vr, t("step1_welcome_name.main")); + return; + case 2: + gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); + return; + case 3: + gatherSpeech(vr, "Perfetto. In quante persone siete?"); + return; + case 4: + gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); + return; + case 5: + gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); + return; case 6: gatherSpeech( vr, @@ -340,13 +358,20 @@ function promptForStep(vr, session) { case 8: gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); return; - case 9: gatherSpeech(vr, t("step8_summary_confirm.main", { - name: session.name || "", - dateLabel: session.dateLabel || "", - time: session.time24 || "", - partySize: session.people || "", - })); return; - default: gatherSpeech(vr, t("step1_welcome_name.short")); return; + case 9: + gatherSpeech( + vr, + t("step8_summary_confirm.main", { + name: session.name || "", + dateLabel: session.dateLabel || "", + time: session.time24 || "", + partySize: session.people || "", + }) + ); + return; + default: + gatherSpeech(vr, t("step1_welcome_name.short")); + return; } } @@ -894,21 +919,23 @@ app.post("/call/outbound", async (req, res) => { if (!to || !isValidPhoneE164(to)) { return res.status(400).json({ ok: false, error: "Invalid 'to' number" }); } - if (!twilioClient || !TWILIO_VOICE_FROM) { + if (!twilioClient) { return res.status(500).json({ ok: false, error: "Twilio not configured" }); } - const twimlUrl = BASE_URL - ? `${BASE_URL}/twilio/voice/outbound` - : "/twilio/voice/outbound"; + const fromNumber = requireTwilioVoiceFrom(); + const twimlUrl = BASE_URL ? `${BASE_URL}/twilio/voice/outbound` : "/twilio/voice/outbound"; const call = await twilioClient.calls.create({ to, - from: TWILIO_VOICE_FROM, + from: fromNumber, url: twimlUrl, method: "POST", }); return res.json({ ok: true, callSid: call.sid }); } catch (err) { console.error("[OUTBOUND] Error:", err); + if (err && err.message === "TWILIO_VOICE_FROM is not set") { + return res.status(500).json({ ok: false, error: "TWILIO_VOICE_FROM not set" }); + } return res.status(500).json({ ok: false }); } }); @@ -1063,7 +1090,9 @@ app.post("/voice", async (req, res) => { session.preorderChoiceKey = null; session.preorderLabel = "nessuno"; } else { - const option = PREORDER_OPTIONS.find((o) => normalized.includes(o.label.toLowerCase()) || normalized.includes(o.key.replace(/_/g, " "))); + const option = PREORDER_OPTIONS.find( + (o) => normalized.includes(o.label.toLowerCase()) || normalized.includes(o.key.replace(/_/g, " ")) + ); if (option) { session.preorderChoiceKey = option.key; session.preorderLabel = option.label; @@ -1110,10 +1139,7 @@ app.post("/voice", async (req, res) => { "Ti ricordo che la sala esterna è senza copertura e con maltempo non posso garantire un tavolo all'interno. Confermi?" ); } else { - gatherSpeech( - vr, - "Posso sistemarvi in tavoli separati? Se preferisci, ti passo un operatore." - ); + gatherSpeech(vr, "Posso sistemarvi in tavoli separati? Se preferisci, ti passo un operatore."); } break; } @@ -1170,23 +1196,29 @@ app.post("/voice", async (req, res) => { break; } session.step = 9; - gatherSpeech(vr, t("step8_summary_confirm.main", { - name: session.name || "", - dateLabel: session.dateLabel || "", - time: session.time24 || "", - partySize: session.people || "", - })); + gatherSpeech( + vr, + t("step8_summary_confirm.main", { + name: session.name || "", + dateLabel: session.dateLabel || "", + time: session.time24 || "", + partySize: session.people || "", + }) + ); break; } case 9: { const confirmation = parseYesNo(speech); if (confirmation === null) { - gatherSpeech(vr, t("step8_summary_confirm.short", { - dateLabel: session.dateLabel || "", - time: session.time24 || "", - partySize: session.people || "", - })); + gatherSpeech( + vr, + t("step8_summary_confirm.short", { + dateLabel: session.dateLabel || "", + time: session.time24 || "", + partySize: session.people || "", + }) + ); break; } if (!confirmation) { From da34d865cf33dd137f6f9e9259a7f311342dd8ad Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 10:55:51 +0100 Subject: [PATCH 058/143] Update app.js From b26afaac7a13123940742fef26b67e0be3c06202 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 11:25:28 +0100 Subject: [PATCH 059/143] Update Twilio voice endpoint and refactor code --- app.js | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 1e69ebc675..9135867dbe 100644 --- a/app.js +++ b/app.js @@ -160,7 +160,7 @@ function sayIt(response, text) { } function gatherSpeech(response, promptText) { - const actionUrl = BASE_URL ? `${BASE_URL}/voice` : "/voice"; + const actionUrl = BASE_URL ? `${BASE_URL}/twilio/voice` : "/twilio/voice"; const gather = response.gather({ input: "speech", language: "it-IT", @@ -444,11 +444,23 @@ function parseDateIT(speech) { } } - const m = tt.match(/\b(\d{1,2})\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/); + const m = tt.match( + /\b(\d{1,2})\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/ + ); if (m) { const months = { - gennaio: 1, febbraio: 2, marzo: 3, aprile: 4, maggio: 5, giugno: 6, - luglio: 7, agosto: 8, settembre: 9, ottobre: 10, novembre: 11, dicembre: 12, + gennaio: 1, + febbraio: 2, + marzo: 3, + aprile: 4, + maggio: 5, + giugno: 6, + luglio: 7, + agosto: 8, + settembre: 9, + ottobre: 10, + novembre: 11, + dicembre: 12, }; const dd = Number(m[1]); const mm = months[m[2]]; @@ -956,7 +968,7 @@ app.post("/twilio/voice/outbound", (req, res) => { } }); -app.post("/voice", async (req, res) => { +async function handleVoiceRequest(req, res) { const callSid = req.body.CallSid || ""; const speech = req.body.SpeechResult || ""; const session = getSession(callSid); @@ -1272,13 +1284,32 @@ app.post("/voice", async (req, res) => { res.set("Content-Type", "text/xml; charset=utf-8"); return res.send(vr.toString()); } +} + +app.all("/twilio/voice", async (req, res) => { + console.log(`[VOICE] ${req.method} /twilio/voice`); + if (req.method !== "POST") { + const vr = buildTwiml(); + vr.say( + { language: "it-IT", voice: "alice" }, + xmlEscape("Test Twilio Voice: endpoint attivo.") + ); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); + } + return handleVoiceRequest(req, res); +}); + +app.all("/voice", (req, res) => { + const targetUrl = BASE_URL ? `${BASE_URL}/twilio/voice` : "/twilio/voice"; + return res.redirect(307, targetUrl); }); // NOTA: /finalize lo rimettiamo dopo, quando confermi che non cade più allo step 5. // (non serve per risolvere il crash delle intolleranze) app.get("/", (req, res) => { - res.send("TuttiBrilli Voice Booking is running. Use POST /voice from Twilio."); + res.send("OK - backend running"); }); app.listen(PORT, () => { From dd9758c55bfc05c207b8248c085c368f229a521e Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 11:25:58 +0100 Subject: [PATCH 060/143] Update voice prompts for TuttiBrilli Enoteca --- prompts.js | 94 ++++++++---------------------------------------------- 1 file changed, 14 insertions(+), 80 deletions(-) diff --git a/prompts.js b/prompts.js index f414c95201..40cca949e5 100644 --- a/prompts.js +++ b/prompts.js @@ -1,100 +1,34 @@ -/** - * TuttiBrilli Enoteca — Voice Prompts (IT) - * SOLO TESTI. Nessuna logica, nessun flusso. - * Placeholders suggeriti: {{name}}, {{dateLabel}}, {{time}}, {{partySize}}, {{guess}} - */ +"use strict"; const PROMPTS = { step1_welcome_name: { - main: "Buonasera, TuttiBrilli Enoteca. Ti do una mano con la prenotazione. Partiamo dal nome: come ti chiami?", - short: "Buonasera, TuttiBrilli. Come ti chiami?", - error: "Perdonami, è andato un po’ via l’audio. Mi ripeti il nome?" + main: "Benvenuto da TuttiBrilli! Come ti chiami?", + short: "Come ti chiami?", }, - step2_confirm_name_ask_date: { - main: "Perfetto, piacere {{name}}. Per che giorno pensavi?", - short: "Perfetto {{name}}. Per che giorno?", - error: "Voglio essere sicuro di aver capito: il nome è {{guess}}?" + main: "Piacere {{name}}. Per quale giorno vuoi prenotare?", }, - step3_confirm_date_ask_time: { - main: "Ok, {{dateLabel}}. A che ora ti va di venire?", - short: "Ok {{dateLabel}}. A che ora?", - error: "Scusami, non l’ho colto bene. Che giorno intendi?", - closedDay: { - main: "Ti fermo solo un attimo: quel giorno siamo chiusi. Se vuoi, guardiamo insieme un’altra data.", - short: "Quel giorno siamo chiusi. Vuoi provare un’altra data?" - }, - todayVariant: "Perfetto, per questa sera. A che ora?" + main: "Perfetto, {{dateLabel}}. A che ora vuoi venire?", + error: "Non ho capito la data. Puoi ripeterla, ad esempio 12 settembre?", }, - step4_confirm_time_ask_party_size: { - main: "Perfetto, alle {{time}}. In quanti sarete?", - short: "Ok {{time}}. In quanti?", - error: "Scusami, l’orario mi è sfuggito. Me lo ripeti?", - outsideHours: { - main: "Ti dico solo una cosa: a quell’ora il locale è ancora chiuso. Se vuoi, possiamo anticipare o spostarci un po’ più tardi.", - short: "A quell’ora siamo chiusi. Vuoi un altro orario?" - }, - kitchenClosed: { - main: "A quell’ora la cucina è chiusa, però siamo aperti per bere qualcosa. Va bene lo stesso o preferisci un altro orario?", - short: "A quell’ora la cucina è chiusa. Va bene lo stesso?" - }, - afterDinner: "Perfetto, quindi dopocena. Quante persone sarete?" + error: "Non ho capito l'orario. Puoi ripeterlo?", }, - step5_party_size_ask_notes: { - main: "Perfetto, {{partySize}} persone. C’è qualche allergia, intolleranza o richiesta particolare?", - short: "Ok {{partySize}}. Allergie o richieste?", - error: "Scusami, non ho capito il numero. Me lo ripeti?", - largeGroupPositive: "Ok, {{partySize}} persone. Ci organizziamo senza problemi. C’è qualche allergia o richiesta particolare?", - checkingAvailability: "Un attimo solo che controllo la disponibilità per {{partySize}} persone. Ci metto pochissimo.", - noAvailability: "Ti dico la verità: per {{partySize}} persone a quell’orario siamo al completo. Se vuoi, proviamo un altro orario o un altro giorno." - }, - - step6_collect_notes: { - main: "Dimmi pure cosa devo segnalare.", - short: "Cosa devo segnare?", - error: "Scusami, non ho capito bene. Me lo ripeti con calma?", - reassure: "Anche solo per segnalarlo alla cucina.", - noneClose: "Perfetto, tutto ok." + main: "Perfetto. Avete richieste particolari o intolleranze?", + error: "Non ho capito quante persone siete. Puoi ripetere?", }, - - step7_whatsapp_number: { - main: "Mi lasci un numero WhatsApp? Ti mando lì la conferma.", - short: "Un numero WhatsApp, per favore.", - reassure: "Solo per la conferma, niente altro.", - error: "Scusami, l’ho perso a metà. Me lo ripeti con calma?", - spokeTooFast: "Perfetto. Me lo ridici tutto di seguito, così non sbagliamo?", - afterCapture: "Ok, perfetto. Un attimo che ti riassumo tutto." - }, - step8_summary_confirm: { - main: "Allora, ricapitoliamo un attimo. {{name}}, {{dateLabel}} alle {{time}}, per {{partySize}} persone. Va bene così?", - short: "Riepilogo veloce: {{dateLabel}}, {{time}}, {{partySize}} persone. Confermi?", - error: "Nessun problema, sistemiamo subito.", - confirmPrompt: "Se per te è tutto ok, confermo la prenotazione.", - confirmShort: "Confermo?", - hesitation: "Prenditi pure un secondo, non c’è fretta.", - outdoorWeather: "Ti segnalo solo una cosa: il tavolo è all’esterno e il meteo è un po’ incerto. Se cambia qualcosa, ci organizziamo senza problemi.", - kitchenNotActive: "A quell’orario la cucina non è attiva, però siamo aperti per bere qualcosa. Va bene lo stesso?", - promoNotValid: "Ti avviso solo che a quell’orario la promo non è attiva. Il resto resta invariato.", - tightAvailability: "È una disponibilità un po’ stretta, ma ci stiamo dentro. Se confermi, blocco subito." + main: "Riepilogo: {{name}}, {{partySize}} persone, {{dateLabel}} alle {{time}}. Confermi?", + short: "Confermi la prenotazione per {{partySize}} persone il {{dateLabel}} alle {{time}}?", }, - step9_success: { - main: "Perfetto, la prenotazione è confermata. Tra poco ricevi un messaggio WhatsApp con tutti i dettagli. Ti aspettiamo da TuttiBrilli.", - short: "Fatto. Ti arriva subito la conferma su WhatsApp.", - reassure: "Sì, è tutto confermato. Tra pochissimo ti arriva il messaggio.", - goodbye: "A presto, buona serata." + main: "Perfetto, la tua prenotazione è confermata. Ti aspettiamo!", }, - step9_fallback_transfer_operator: { - main: "Un attimo solo, così ti seguiamo al meglio. Ti passo subito un collega.", - short: "Ti metto in contatto con un collega.", - gentle: "Capisco, meglio sentirci un attimo a voce. Resta in linea, ti passo un collega.", - value: "Così riusciamo a trovare la soluzione migliore per te." - } + main: "Ti passo un operatore per completare la richiesta.", + }, }; module.exports = { PROMPTS }; From ae2a8977ded4e2a9424bbed09e8e6a892e5ce1e3 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 11:40:59 +0100 Subject: [PATCH 061/143] Implement SMS notification for critical reservations Added async function to send critical reservation SMS via Twilio. --- app.js | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/app.js b/app.js index 9135867dbe..51aa9a6af8 100644 --- a/app.js +++ b/app.js @@ -155,7 +155,6 @@ function buildTwiml() { } function sayIt(response, text) { - // ESCAPE per evitare XML rotto (’ ecc) response.say({ language: "it-IT" }, xmlEscape(text)); } @@ -168,7 +167,6 @@ function gatherSpeech(response, promptText) { action: actionUrl, method: "POST", }); - // Anche qui escape gather.say({ language: "it-IT" }, xmlEscape(promptText)); } @@ -210,6 +208,21 @@ async function notifyCriticalReservation(phone, summary) { } } +async function sendCriticalReservationSms(summary) { + if (!twilioClient) return null; + try { + const fromNumber = requireTwilioVoiceFrom(); + return await twilioClient.messages.create({ + to: "+393881669661", + from: fromNumber, + body: `Prenotazione con criticità. ${summary || ""}`.trim(), + }); + } catch (err) { + console.error("[TWILIO] Critical SMS failed:", err); + return null; + } +} + // ======================= SESSION ======================= const sessions = new Map(); @@ -1186,6 +1199,7 @@ async function handleVoiceRequest(req, res) { const calendarEvent = await createCalendarEvent(session); const summary = `Data ${session.dateLabel || session.dateISO || ""}, ore ${session.time24 || ""}, ${session.people || ""} persone.`; await notifyCriticalReservation(session.phone, summary); + await sendCriticalReservationSms(summary); if (calendarEvent?.status === "closed") { session.step = 2; session.criticalReservation = false; @@ -1288,16 +1302,18 @@ async function handleVoiceRequest(req, res) { app.all("/twilio/voice", async (req, res) => { console.log(`[VOICE] ${req.method} /twilio/voice`); - if (req.method !== "POST") { - const vr = buildTwiml(); - vr.say( - { language: "it-IT", voice: "alice" }, - xmlEscape("Test Twilio Voice: endpoint attivo.") - ); - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); + const payload = req.method === "POST" ? req.body : req.query; + if (payload && Object.keys(payload).length > 0) { + req.body = payload; + return handleVoiceRequest(req, res); } - return handleVoiceRequest(req, res); + const vr = buildTwiml(); + vr.say( + { language: "it-IT", voice: "alice" }, + xmlEscape("Test Twilio Voice: endpoint attivo.") + ); + res.set("Content-Type", "text/xml; charset=utf-8"); + return res.send(vr.toString()); }); app.all("/voice", (req, res) => { From 9807677ad98978f4069fe7c9f7c21d86dcf20a6c Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 11:41:30 +0100 Subject: [PATCH 062/143] Update prompts.js From 19cfa52280139f3501fdb4bfe79a877415c95e18 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 21:41:27 +0100 Subject: [PATCH 063/143] Create schema.prisma --- prisma/schema.prisma | 140 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 prisma/schema.prisma diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000000..fc130165b4 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,140 @@ +// ============================== +// DATASOURCE & GENERATOR +// ============================== +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} + +// ============================== +// ENUM +// ============================== +enum Role { + OWNER + ADMIN + SALA + CUCINA +} + +enum BookingStatus { + REQUESTED + CONFIRMED + CANCELLED +} + +enum SaleCategory { + VINO + FOOD + BIBITE +} + +// ============================== +// MODELS +// ============================== + +model Locale { + id String @id @default(uuid()) + name String + createdAt DateTime @default(now()) + + users User[] + businessDays BusinessDay[] + bookings Booking[] +} + +model User { + id String @id @default(uuid()) + email String @unique + passwordHash String + role Role + isActive Boolean @default(true) + + localeId String + locale Locale @relation(fields: [localeId], references: [id]) + + createdAt DateTime @default(now()) +} + +model BusinessDay { + id String @id @default(uuid()) + localeId String + locale Locale @relation(fields: [localeId], references: [id]) + + dateISO String // es: 2026-03-18 + isClosed Boolean @default(false) + notes String? + + bookings Booking[] + sales Sale[] + presences Presence[] + rating ServiceRating? + + createdAt DateTime @default(now()) + + @@unique([localeId, dateISO]) +} + +model Booking { + id String @id @default(uuid()) + localeId String + businessDayId String + + locale Locale @relation(fields: [localeId], references: [id]) + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) + + name String + time24 String // HH:MM + people Int + status BookingStatus @default(REQUESTED) + + phone String? + whatsapp String? + notes String? + + calendarEventId String? // Google Calendar event ID + createdBy String // user | assistant + + createdAt DateTime @default(now()) +} + +model Presence { + id String @id @default(uuid()) + businessDayId String + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) + + timeSlot String // es: 19-20 + expectedPeople Int + actualPeople Int + + createdAt DateTime @default(now()) +} + +model Sale { + id String @id @default(uuid()) + businessDayId String + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) + + category SaleCategory + productName String + quantity Int + totalAmount Float + + createdAt DateTime @default(now()) +} + +model ServiceRating { + id String @id @default(uuid()) + businessDayId String @unique + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) + + customerSatisfaction Int // 1–5 + serviceEfficiency Int // 1–5 + kitchenEfficiency Int // 1–5 + notes String? + + createdAt DateTime @default(now()) +} From 769ae56aaa71cd196f06f214603cf0b471c54cf6 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 21:43:42 +0100 Subject: [PATCH 064/143] Update schema.prisma From d5cd60d4aee7d84abe09649231448005aca9904a Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 21:53:43 +0100 Subject: [PATCH 065/143] add prisma dependencies and build script --- package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b239b6933b..6b7f83a2e1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Assistente vocale AI per TuttiBrilli (Twilio + Google Calendar)", "main": "app.js", "scripts": { - "start": "node app.js" + "start": "node app.js", + "build": "prisma generate" }, "engines": { "node": ">=18" @@ -12,6 +13,10 @@ "dependencies": { "express": "^4.19.2", "googleapis": "^169.0.0", - "twilio": "^5.11.1" + "twilio": "^5.11.1", + "@prisma/client": "^5.22.0" + }, + "devDependencies": { + "prisma": "^5.22.0" } } From 5c3f4c20fea49ca83834825df623c7b94459a528 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:01:17 +0100 Subject: [PATCH 066/143] fix prisma install on render --- package.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 6b7f83a2e1..e17d362f3d 100644 --- a/package.json +++ b/package.json @@ -5,18 +5,17 @@ "main": "app.js", "scripts": { "start": "node app.js", - "build": "prisma generate" + "postinstall": "prisma generate", + "build": "echo \"build ok\"" }, "engines": { "node": ">=18" }, "dependencies": { + "@prisma/client": "^5.22.0", "express": "^4.19.2", "googleapis": "^169.0.0", "twilio": "^5.11.1", - "@prisma/client": "^5.22.0" - }, - "devDependencies": { "prisma": "^5.22.0" } } From 7c63feea979bcbfc9f27d8b55ac195f9802a2960 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:04:30 +0100 Subject: [PATCH 067/143] Update package.json for Prisma setup --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e17d362f3d..6b7f83a2e1 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,18 @@ "main": "app.js", "scripts": { "start": "node app.js", - "postinstall": "prisma generate", - "build": "echo \"build ok\"" + "build": "prisma generate" }, "engines": { "node": ">=18" }, "dependencies": { - "@prisma/client": "^5.22.0", "express": "^4.19.2", "googleapis": "^169.0.0", "twilio": "^5.11.1", + "@prisma/client": "^5.22.0" + }, + "devDependencies": { "prisma": "^5.22.0" } } From 1f7dab99f1a2b060b6b9765f02dab0fa789767f0 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:10:15 +0100 Subject: [PATCH 068/143] Update package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6b7f83a2e1..9b593a511b 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,10 @@ "node": ">=18" }, "dependencies": { + "@prisma/client": "^5.22.0", "express": "^4.19.2", "googleapis": "^169.0.0", - "twilio": "^5.11.1", - "@prisma/client": "^5.22.0" + "twilio": "^5.11.1" }, "devDependencies": { "prisma": "^5.22.0" From f5437bc73c74a72862d7ba3c900a83ad5b0ec11d Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:37:10 +0100 Subject: [PATCH 069/143] Update app.js --- app.js | 1624 +++++++++++++------------------------------------------- 1 file changed, 373 insertions(+), 1251 deletions(-) diff --git a/app.js b/app.js index 51aa9a6af8..9dcd04aff1 100644 --- a/app.js +++ b/app.js @@ -1,1333 +1,455 @@ -"use strict"; - -const fs = require("fs"); -const path = require("path"); - -// dotenv opzionale (su Render non serve) -const dotenvModuleDir = path.join(__dirname, "node_modules", "dotenv"); -const dotenvPackageJson = path.join(dotenvModuleDir, "package.json"); -const dotenvEntryPoint = path.join(dotenvModuleDir, "index.js"); -const hasDotenv = - fs.existsSync(dotenvPackageJson) || - fs.existsSync(dotenvEntryPoint) || - fs.existsSync(dotenvModuleDir); -if (hasDotenv) { - require("dotenv").config(); -} - const express = require("express"); -const twilio = require("twilio"); -const bodyParser = require("body-parser"); -const { google } = require("googleapis"); - -const { PROMPTS } = require("./prompts"); +const dotenv = require("dotenv"); +const { + createGatherResponse, + createSayResponse, + createMessageResponse, + normalizeSpeechText, + guessDateFromSpeech, + guessTimeFromSpeech, + extractNumberFromSpeech, + createDialResponse, + sendWhatsAppMessage, + createOutboundCall, +} = require("./lib/twilio"); +const { getPrismaClient, dbAvailable } = require("./lib/db"); +const { createBookingEvent, createTestEvent } = require("./lib/calendar"); + +dotenv.config(); const app = express(); -app.use(bodyParser.urlencoded({ extended: false })); +app.use(express.urlencoded({ extended: false })); app.use(express.json()); -// ======================= ENV ======================= -const PORT = process.env.PORT || 3000; -const BASE_URL = process.env.BASE_URL || ""; - -const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ""; -const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ""; -const TWILIO_VOICE_FROM = process.env.TWILIO_VOICE_FROM || ""; -const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID || ""; -const GOOGLE_CALENDAR_TZ = process.env.GOOGLE_CALENDAR_TZ || "Europe/Rome"; -const GOOGLE_SERVICE_ACCOUNT_JSON_B64 = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64 || ""; -const GOOGLE_SERVICE_ACCOUNT_JSON = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || ""; - -const ENABLE_FORWARDING = (process.env.ENABLE_FORWARDING || "false").toLowerCase() === "true"; -const HUMAN_FORWARD_TO = process.env.HUMAN_FORWARD_TO || ""; -const CRITICAL_COLOR_ID = process.env.CRITICAL_COLOR_ID || "11"; - -const HOLIDAYS_YYYY_MM_DD = process.env.HOLIDAYS_YYYY_MM_DD || ""; -const HOLIDAYS_SET = new Set(HOLIDAYS_YYYY_MM_DD.split(",").map((s) => s.trim()).filter(Boolean)); - -const twilioClient = - TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN ? twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) : null; - -const YES_WORDS = ["si", "sì", "certo", "confermo", "ok", "va bene", "perfetto", "esatto"]; -const NO_WORDS = ["no", "non", "annulla", "cancella", "negativo"]; -const CANCEL_WORDS = ["annulla", "annullare", "cancella", "modifica"]; - -// ======================= OPENING HOURS ======================= -const OPENING = { - closedDay: 1, // Monday - restaurant: { - default: { start: "18:30", end: "22:30" }, - friSat: { start: "18:30", end: "23:00" }, - }, - drinksOnly: { start: "18:30", end: "24:00" }, - musicNights: { days: [3, 5] }, // Wed & Fri -}; - -// ======================= PREORDER MENU ======================= -const PREORDER_OPTIONS = [ - { key: "cena", label: "Cena", priceEUR: null, constraints: {} }, - { key: "apericena", label: "Apericena", priceEUR: null, constraints: {} }, - { key: "dopocena", label: "Dopocena (dopo le 22:30)", priceEUR: null, constraints: { minTime: "22:30" } }, - { key: "piatto_apericena", label: "Piatto Apericena", priceEUR: 25, constraints: {} }, - { key: "piatto_apericena_promo", label: "Piatto Apericena in promo (previa registrazione)", priceEUR: null, constraints: { promoOnly: true } }, -]; - -function getPreorderOptionByKey(key) { - return PREORDER_OPTIONS.find((o) => o.key === key) || null; -} - -// ======================= TABLES ======================= -const TABLES = [ - { id: "T1", area: "inside", min: 2, max: 4, notes: "più riservato" }, - { id: "T2", area: "inside", min: 2, max: 4, notes: "più riservato" }, - { id: "T3", area: "inside", min: 2, max: 4, notes: "più riservato" }, - { id: "T4", area: "inside", min: 2, max: 4, notes: "più riservato" }, - { id: "T5", area: "inside", min: 2, max: 2 }, - { id: "T6", area: "inside", min: 2, max: 4 }, - { id: "T7", area: "inside", min: 2, max: 4 }, - { id: "T8", area: "inside", min: 2, max: 4 }, - { id: "T9", area: "inside", min: 2, max: 2 }, - { id: "T10", area: "inside", min: 2, max: 2 }, - { id: "T11", area: "inside", min: 2, max: 4, notes: "vicino ingresso" }, - { id: "T12", area: "inside", min: 2, max: 4 }, - { id: "T13", area: "inside", min: 2, max: 4 }, - { id: "T14", area: "inside", min: 4, max: 8, notes: "divanetto con tavolino" }, - { id: "T15", area: "inside", min: 4, max: 8, notes: "divanetto con tavolino" }, - { id: "T16", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, - { id: "T17", area: "inside", min: 4, max: 5, notes: "tavolo alto con sgabelli" }, - - { id: "T1F", area: "outside", min: 2, max: 2, notes: "botte con sgabelli" }, - { id: "T2F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, - { id: "T3F", area: "outside", min: 2, max: 2, notes: "tavolo alto con sgabelli" }, - { id: "T4F", area: "outside", min: 4, max: 5, notes: "divanetti" }, - { id: "T6F", area: "outside", min: 4, max: 4, notes: "divanetti" }, - { id: "T7F", area: "outside", min: 4, max: 4, notes: "divanetti" }, - { id: "T8F", area: "outside", min: 4, max: 4, notes: "divanetti" }, -]; - -const TABLE_COMBINATIONS = [ - { displayId: "T1", area: "inside", replaces: ["T1", "T2"], min: 6, max: 6, notes: "unione T1+T2" }, - { displayId: "T3", area: "inside", replaces: ["T3", "T4"], min: 6, max: 6, notes: "unione T3+T4" }, - { displayId: "T14", area: "inside", replaces: ["T14", "T15"], min: 8, max: 18, notes: "unione T14+T15" }, - { displayId: "T11", area: "inside", replaces: ["T11", "T12"], min: 6, max: 6, notes: "unione T11+T12" }, - { displayId: "T12", area: "inside", replaces: ["T12", "T13"], min: 6, max: 6, notes: "unione T12+T13" }, - { displayId: "T11", area: "inside", replaces: ["T11", "T12", "T13"], min: 8, max: 10, notes: "unione T11+T12+T13" }, - { displayId: "T16", area: "inside", replaces: ["T16", "T17"], min: 8, max: 10, notes: "unione T16+T17" }, - { displayId: "T7F", area: "outside", replaces: ["T7F", "T8F"], min: 6, max: 8, notes: "unione T7F+T8F" }, -]; - -// ======================= XML SAFE TEXT ======================= -function xmlEscape(s) { - return String(s || "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -// ======================= PROMPTS HELPERS ======================= -function renderTemplate(str, vars = {}) { - const s = String(str || ""); - return s.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { - const v = vars[key]; - return v === undefined || v === null ? "" : String(v); - }); -} - -function pickPrompt(path, fallback = "") { - const parts = String(path || "").split("."); - let node = PROMPTS; - for (const p of parts) { - if (!node || typeof node !== "object" || !(p in node)) return fallback; - node = node[p]; - } - return typeof node === "string" ? node : fallback; -} - -function t(path, vars = {}, fallback = "") { - return renderTemplate(pickPrompt(path, fallback), vars); -} - -// ======================= TWILIO HELPERS ======================= -function buildTwiml() { - return new twilio.twiml.VoiceResponse(); -} - -function sayIt(response, text) { - response.say({ language: "it-IT" }, xmlEscape(text)); -} - -function gatherSpeech(response, promptText) { - const actionUrl = BASE_URL ? `${BASE_URL}/twilio/voice` : "/twilio/voice"; - const gather = response.gather({ - input: "speech", - language: "it-IT", - speechTimeout: "auto", - action: actionUrl, - method: "POST", - }); - gather.say({ language: "it-IT" }, xmlEscape(promptText)); -} - -function isValidPhoneE164(s) { - return /^\+\d{8,15}$/.test(String(s || "").trim()); -} -function canForwardToHuman() { - return ENABLE_FORWARDING && Boolean(HUMAN_FORWARD_TO) && isValidPhoneE164(HUMAN_FORWARD_TO); -} - -function requireTwilioVoiceFrom() { - if (!TWILIO_VOICE_FROM) { - throw new Error("TWILIO_VOICE_FROM is not set"); - } - return TWILIO_VOICE_FROM; -} - -function forwardToHumanTwiml() { - const vr = buildTwiml(); - sayIt(vr, t("step9_fallback_transfer_operator.main")); - vr.dial({}, HUMAN_FORWARD_TO); - return vr.toString(); -} - -async function notifyCriticalReservation(phone, summary) { - if (!twilioClient) return null; - try { - const fromNumber = requireTwilioVoiceFrom(); - return await twilioClient.calls.create({ - to: "+393881669661", - from: fromNumber, - twiml: new twilio.twiml.VoiceResponse() - .say({ language: "it-IT" }, xmlEscape(`Prenotazione con criticità. ${summary || ""}`)) - .toString(), - }); - } catch (err) { - console.error("[TWILIO] Critical notify failed:", err); - return null; - } -} - -async function sendCriticalReservationSms(summary) { - if (!twilioClient) return null; - try { - const fromNumber = requireTwilioVoiceFrom(); - return await twilioClient.messages.create({ - to: "+393881669661", - from: fromNumber, - body: `Prenotazione con criticità. ${summary || ""}`.trim(), - }); - } catch (err) { - console.error("[TWILIO] Critical SMS failed:", err); - return null; - } -} - -// ======================= SESSION ======================= -const sessions = new Map(); +const bookingSessions = new Map(); +const MAX_ATTEMPTS = 3; function getSession(callSid) { - if (!callSid) return null; - if (!sessions.has(callSid)) { - sessions.set(callSid, { - step: 1, - retries: 0, - name: null, - dateISO: null, - dateLabel: null, - time24: null, - people: null, - specialRequestsRaw: null, - preorderChoiceKey: null, - preorderLabel: null, - area: null, - pendingOutsideConfirm: false, - phone: null, - waTo: null, - tableDisplayId: null, - tableLocks: [], - splitRequired: false, - outsideRequired: false, - criticalReservation: false, - calendarEventId: null, - tableNotes: null, - durationMinutes: null, - bookingType: "restaurant", - autoConfirm: true, - promoEligible: null, + if (!bookingSessions.has(callSid)) { + bookingSessions.set(callSid, { + step: "intent", + attempts: 0, + data: {}, }); } - return sessions.get(callSid); -} - -function bumpRetries(session) { - session.retries = (session.retries || 0) + 1; - return session.retries; -} -function resetRetries(session) { - session.retries = 0; + return bookingSessions.get(callSid); } -function normalizeText(s) { - return String(s || "").trim().toLowerCase().replace(/\s+/g, " "); -} -function toISODate(d) { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; +function resetAttempts(session) { + session.attempts = 0; } -function formatDateLabel(dateISO) { - const date = new Date(`${dateISO}T00:00:00`); - const weekdays = [ - "domenica", - "lunedì", - "martedì", - "mercoledì", - "giovedì", - "venerdì", - "sabato", - ]; - const months = [ - "gennaio", - "febbraio", - "marzo", - "aprile", - "maggio", - "giugno", - "luglio", - "agosto", - "settembre", - "ottobre", - "novembre", - "dicembre", - ]; - return `${weekdays[date.getDay()]} ${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`; +function incrementAttempts(session) { + session.attempts += 1; } -// ---- Back/edit commands -function isBackCommand(speech) { - const tt = normalizeText(speech); +function needsHumanForwarding() { return ( - tt.includes("indietro") || - tt.includes("torna indietro") || - tt.includes("tornare indietro") || - tt.includes("errore") || - tt.includes("ho sbagliato") || - tt.includes("sbagliato") - ); -} - -function isCancelCommand(speech) { - const tt = normalizeText(speech); - return CANCEL_WORDS.some((word) => tt.includes(word)); -} - -function goBack(session) { - if (!session || typeof session.step !== "number") return; - if (session.step <= 1) return; - - if (session.step === 2) session.step = 1; - else if (session.step === 3) session.step = 2; - else if (session.step === 4) session.step = 3; - else if (session.step === 5) session.step = 4; - else if (session.step === 6) session.step = 5; - else if (session.step === 7) session.step = 6; - else if (session.step === 8) session.step = 7; - else if (session.step === 9) session.step = 8; - else session.step = Math.max(1, session.step - 1); -} - -function promptForStep(vr, session) { - switch (session.step) { - case 1: - gatherSpeech(vr, t("step1_welcome_name.main")); - return; - case 2: - gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name || "" })); - return; - case 3: - gatherSpeech(vr, "Perfetto. In quante persone siete?"); - return; - case 4: - gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel || "" })); - return; - case 5: - gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people || "" })); - return; - case 6: - gatherSpeech( - vr, - "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." - ); - return; - case 7: - gatherSpeech( - vr, - "Non abbiamo più disponibilità per un unico tavolo. Posso sistemarvi in tavoli separati?" - ); - return; - case 8: - gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); - return; - case 9: - gatherSpeech( - vr, - t("step8_summary_confirm.main", { - name: session.name || "", - dateLabel: session.dateLabel || "", - time: session.time24 || "", - partySize: session.people || "", - }) - ); - return; - default: - gatherSpeech(vr, t("step1_welcome_name.short")); - return; - } -} - -// ====== parsing basilari (per arrivare al punto: FIX crash step 5) ====== -function parseTimeIT(speech) { - const tt = normalizeText(speech); - const hm = tt.match(/(\d{1,2})[:\s](\d{2})/); - if (hm) { - const hh = Number(hm[1]); - const mm = Number(hm[2]); - if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { - return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; - } - } - const onlyH = tt.match(/\b(\d{1,2})\b/); - if (onlyH) { - const hh = Number(onlyH[1]); - if (hh >= 0 && hh <= 23) return `${String(hh).padStart(2, "0")}:00`; - } - return null; -} - -function parsePeopleIT(speech) { - const tt = normalizeText(speech); - const m = tt.match(/\b(\d{1,2})\b/); - if (!m) return null; - const n = Number(m[1]); - if (!Number.isFinite(n) || n <= 0) return null; - return n; -} - -function parseYesNo(speech) { - const tt = normalizeText(speech); - if (!tt) return null; - if (YES_WORDS.some((w) => tt.includes(w))) return true; - if (NO_WORDS.some((w) => tt.includes(w))) return false; - return null; -} - -function parsePhoneNumber(speech) { - if (!speech) return null; - if (isValidPhoneE164(speech)) return speech.trim(); - const digits = String(speech).replace(/[^\d]/g, ""); - if (digits.length >= 8 && digits.length <= 15) { - if (digits.length <= 10) return `+39${digits}`; - return `+${digits}`; - } - return null; -} - -// ---- Date: user-friendly (stesso parser robusto, qui versione breve per stabilità) -function parseDateIT(speech) { - const tt = normalizeText(speech).replace(/[,\.]/g, " ").replace(/\s+/g, " ").trim(); - const today = new Date(); - - if (tt.includes("oggi")) return toISODate(today); - if (tt.includes("domani")) { - const d = new Date(today.getTime() + 24 * 60 * 60 * 1000); - return toISODate(d); - } - - const dmY = tt.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); - if (dmY) { - let dd = Number(dmY[1]); - let mm = Number(dmY[2]); - let yy = dmY[3] ? Number(dmY[3]) : today.getFullYear(); - if (yy < 100) yy += 2000; - if (dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12) { - return toISODate(new Date(yy, mm - 1, dd)); - } - } - - const m = tt.match( - /\b(\d{1,2})\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)(?:\s+(\d{2,4}))?\b/ + String(process.env.FORWARDING_ENABLED || "").toLowerCase() === "true" && + Boolean(process.env.HUMAN_FORWARD_TO) ); - if (m) { - const months = { - gennaio: 1, - febbraio: 2, - marzo: 3, - aprile: 4, - maggio: 5, - giugno: 6, - luglio: 7, - agosto: 8, - settembre: 9, - ottobre: 10, - novembre: 11, - dicembre: 12, - }; - const dd = Number(m[1]); - const mm = months[m[2]]; - let yy = m[3] ? Number(m[3]) : today.getFullYear(); - if (yy < 100) yy += 2000; - if (dd >= 1 && dd <= 31 && mm) return toISODate(new Date(yy, mm - 1, dd)); - } - - return null; } -function getGoogleCredentials() { - if (GOOGLE_SERVICE_ACCOUNT_JSON_B64) { - try { - const decoded = Buffer.from(GOOGLE_SERVICE_ACCOUNT_JSON_B64, "base64").toString("utf8"); - return JSON.parse(decoded); - } catch (err) { - console.error("[GOOGLE] Invalid base64 credentials:", err); - } - } - if (GOOGLE_SERVICE_ACCOUNT_JSON) { - try { - return JSON.parse(GOOGLE_SERVICE_ACCOUNT_JSON); - } catch (err) { - console.error("[GOOGLE] Invalid JSON credentials:", err); - } +function createForwardingTwiml() { + if (!needsHumanForwarding()) { + return createSayResponse("Non riesco a capire. Ti richiameremo a breve."); } - return null; -} - -function buildCalendarClient() { - const credentials = getGoogleCredentials(); - if (!credentials) return null; - const auth = new google.auth.GoogleAuth({ - credentials, - scopes: ["https://www.googleapis.com/auth/calendar"], - }); - return google.calendar({ version: "v3", auth }); -} - -function getTimeRangeForDate(dateISO) { - const start = new Date(`${dateISO}T00:00:00`); - const end = new Date(`${dateISO}T23:59:59`); - return { start, end }; -} - -function isDateClosedByCalendar(events) { - return events.some((event) => { - const summary = String(event.summary || "").toLowerCase(); - const description = String(event.description || "").toLowerCase(); - return summary.includes("locale chiuso") || description.includes("locale chiuso"); - }); -} - -function extractTablesFromEvent(event) { - const description = String(event.description || ""); - const match = description.match(/Tavolo:\s*([^\n]+)/i); - const summary = String(event.summary || ""); - const summaryMatch = summary.match(/tav(?:olo)?\s*([^\-,]+)/i); - const tableText = match?.[1] || summaryMatch?.[1]; - if (!tableText) return []; - return tableText - .split(/,| e /i) - .map((entry) => normalizeTableId(entry)) - .filter(Boolean); -} - -function getEventTimeRange(event) { - const start = event.start?.dateTime || event.start?.date; - const end = event.end?.dateTime || event.end?.date; - if (!start || !end) return null; - return { - start: new Date(start), - end: new Date(end), - }; -} - -function overlapsRange(a, b) { - return a.start < b.end && b.start < a.end; -} - -function normalizeTableId(raw) { - const cleaned = String(raw || "") - .replace(/tav(?:olo)?/i, "") - .replace(/[^\dA-Z]/gi, "") - .toUpperCase(); - if (!cleaned) return null; - return cleaned.startsWith("T") ? cleaned : `T${cleaned}`; -} - -function expandTableLocks(tableId) { - const combo = TABLE_COMBINATIONS.find((c) => c.displayId === tableId); - if (combo) return combo.replaces; - return [tableId]; -} - -function buildAvailableTables(occupied, availableOverride) { - return TABLES.filter((table) => { - if (occupied.has(table.id)) return false; - if (availableOverride && !availableOverride.has(table.id)) return false; - return true; - }); -} - -function getTableById(id) { - return TABLES.find((table) => table.id === id) || null; -} - -function buildAvailableTableSet(availableTables) { - return new Set(availableTables.map((table) => table.id)); + return createDialResponse(process.env.HUMAN_FORWARD_TO); } -function pickTableForParty(people, occupied, availableOverride) { - const availableTables = buildAvailableTables(occupied, availableOverride); - const availableSet = buildAvailableTableSet(availableTables); - const direct = availableTables.find((table) => people >= table.min && people <= table.max); - if (direct) return { displayId: direct.id, locks: [direct.id], notes: direct.notes || null }; - - for (const combo of TABLE_COMBINATIONS) { - if (people < combo.min || people > combo.max) continue; - const unavailable = combo.replaces.some((id) => occupied.has(id) || !availableSet.has(id)); - if (!unavailable) { - return { displayId: combo.displayId, locks: combo.replaces, notes: combo.notes || null }; - } - } - return null; -} +app.get("/", (req, res) => { + res + .status(200) + .send("TuttiBrilli backend attivo ✅ (usa /healthz, /voice, /whatsapp)"); +}); -function pickSplitTables(people, availableTables) { - const sorted = [...availableTables].sort((a, b) => b.max - a.max); - const selected = []; - let remaining = people; - let capacity = 0; +app.get("/healthz", (req, res) => { + res.status(200).json({ status: "ok" }); +}); - for (const table of sorted) { - if (remaining <= 0) break; - selected.push(table); - capacity += table.max; - remaining -= table.max; +app.get("/debug/db", async (req, res) => { + if (!dbAvailable()) { + return res.status(200).json({ ok: false, reason: "DATABASE_URL missing" }); } - - if (capacity < people || selected.length === 0) return null; - return { - displayIds: selected.map((table) => table.id), - locks: selected.map((table) => table.id), - notes: "tavoli separati", - }; -} - -function getNextDateISO(dateISO) { - const date = new Date(`${dateISO}T00:00:00`); - date.setDate(date.getDate() + 1); - return toISODate(date); -} - -async function listCalendarEvents(dateISO) { - if (!GOOGLE_CALENDAR_ID) return []; - const calendar = buildCalendarClient(); - if (!calendar) return []; - const { start, end } = getTimeRangeForDate(dateISO); + const prisma = getPrismaClient(); try { - const result = await calendar.events.list({ - calendarId: GOOGLE_CALENDAR_ID, - timeMin: start.toISOString(), - timeMax: end.toISOString(), - singleEvents: true, - orderBy: "startTime", - }); - return result?.data?.items || []; - } catch (err) { - console.error("[GOOGLE] Calendar list failed:", err); - return []; - } -} - -function formatTimeSlot(date, startDate) { - if (!date) return ""; - if ( - startDate && - date.getHours() === 0 && - date.getMinutes() === 0 && - date.getTime() > startDate.getTime() - ) { - return "24.00"; - } - const hh = String(date.getHours()).padStart(2, "0"); - const mm = String(date.getMinutes()).padStart(2, "0"); - return `${hh}.${mm}`; -} - -function buildAvailabilityDescription(dateISO, events) { - const tableIds = TABLES.map((table) => table.id); - const occupancy = new Map(tableIds.map((id) => [id, []])); - const bookingRange = { - start: new Date(`${dateISO}T00:00:00`), - end: new Date(`${dateISO}T23:59:59`), - }; - - for (const event of events) { - const summary = String(event.summary || "").toLowerCase(); - if (summary.startsWith("annullata")) continue; - if (summary.includes("tavoli disponibili")) continue; - if (summary.includes("locale chiuso")) continue; - if (summary.includes("evento")) continue; - const eventRange = getEventTimeRange(event); - if (!eventRange || !overlapsRange(bookingRange, eventRange)) continue; - const tableIdsForEvent = extractTablesFromEvent(event) - .flatMap(expandTableLocks) - .filter((id) => occupancy.has(id)); - for (const tableId of tableIdsForEvent) { - occupancy.get(tableId).push({ - start: eventRange.start, - end: eventRange.end, - }); - } + const [locali, bookings] = await Promise.all([ + prisma.locale.count(), + prisma.booking.count(), + ]); + return res.status(200).json({ ok: true, locali, bookings }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); } +}); - const lines = tableIds.map((tableId) => { - const slots = occupancy.get(tableId) || []; - slots.sort((a, b) => a.start - b.start); - if (slots.length === 0) { - return `${tableId}:`; - } - const slotText = slots - .map((slot) => { - const start = formatTimeSlot(slot.start); - const end = formatTimeSlot(slot.end, slot.start); - return `occupato dalle ${start} alle ${end};`; - }) - .join(" "); - return `${tableId}: ${slotText}`; - }); - - return lines.join("\n"); -} - -async function upsertAvailabilityEvent(dateISO) { - if (!GOOGLE_CALENDAR_ID) return null; - const calendar = buildCalendarClient(); - if (!calendar) return null; - const events = await listCalendarEvents(dateISO); - const availabilityEvent = events.find((event) => - String(event.summary || "").toLowerCase().includes("tavoli disponibili") - ); - const description = buildAvailabilityDescription(dateISO, events); - const requestBody = { - summary: "Tavoli disponibili", - description, - start: { date: dateISO, timeZone: GOOGLE_CALENDAR_TZ }, - end: { date: getNextDateISO(dateISO), timeZone: GOOGLE_CALENDAR_TZ }, - }; - - try { - if (availabilityEvent?.id) { - const result = await calendar.events.patch({ - calendarId: GOOGLE_CALENDAR_ID, - eventId: availabilityEvent.id, - requestBody, - }); - return result?.data || null; - } - const result = await calendar.events.insert({ - calendarId: GOOGLE_CALENDAR_ID, - requestBody, - }); - return result?.data || null; - } catch (err) { - console.error("[GOOGLE] Availability event update failed:", err); - return null; - } -} - -async function reserveTableForSession(session, { commit } = { commit: false }) { - const events = await listCalendarEvents(session.dateISO); - if (isDateClosedByCalendar(events)) { - return { status: "closed" }; - } - - const bookingStart = new Date(`${session.dateISO}T${session.time24}:00`); - const bookingEnd = new Date(bookingStart.getTime() + 120 * 60 * 1000); - const bookingRange = { start: bookingStart, end: bookingEnd }; - const occupied = new Set(); - const eventoEvents = []; - - for (const event of events) { - const summary = String(event.summary || "").toLowerCase(); - if (summary.startsWith("annullata")) continue; - if (summary.includes("evento")) { - eventoEvents.push(event); - continue; - } - const eventRange = getEventTimeRange(event); - if (!eventRange || !overlapsRange(bookingRange, eventRange)) continue; - const tableIds = extractTablesFromEvent(event); - tableIds.flatMap(expandTableLocks).forEach((id) => occupied.add(id)); - } - - let availableOverride = null; - if (eventoEvents.length > 0) { - const eventTables = eventoEvents - .flatMap((event) => extractTablesFromEvent(event)) - .flatMap((id) => expandTableLocks(id)); - availableOverride = new Set(eventTables); - } - - const selection = pickTableForParty(session.people, occupied, availableOverride); - if (!selection) { - const availableTables = buildAvailableTables(occupied, availableOverride); - const insideTables = availableTables.filter((table) => table.area === "inside"); - const insideSplit = pickSplitTables(session.people, insideTables); - if (insideSplit) { - session.tableDisplayId = insideSplit.displayIds.join(" e "); - session.tableLocks = insideSplit.locks; - session.tableNotes = insideSplit.notes; - session.splitRequired = true; - session.outsideRequired = false; - return { status: "needs_split" }; - } - - const anySplit = pickSplitTables(session.people, availableTables); - if (anySplit) { - session.tableDisplayId = anySplit.displayIds.join(" e "); - session.tableLocks = anySplit.locks; - session.tableNotes = anySplit.notes; - session.splitRequired = true; - session.outsideRequired = anySplit.locks.some((id) => getTableById(id)?.area === "outside"); - return { status: "needs_outside" }; - } - - return { status: "unavailable" }; - } - session.tableDisplayId = selection.displayId; - session.tableLocks = selection.locks; - session.tableNotes = selection.notes; - session.splitRequired = false; - session.outsideRequired = selection.locks.some((id) => getTableById(id)?.area === "outside"); - - if (commit && eventoEvents.length > 0) { - const calendar = buildCalendarClient(); - if (calendar) { - for (const event of eventoEvents) { - const eventTables = extractTablesFromEvent(event); - const remaining = eventTables.filter((id) => !selection.locks.includes(id)); - const availableCount = remaining.length; - const updatedSummary = `Evento - tavoli disponibili: ${availableCount}`; - const baseDescription = String(event.description || ""); - const updatedDescription = baseDescription.match(/Tavolo:\s*/i) - ? baseDescription.replace(/Tavolo:\s*[^\n]*/i, `Tavolo: ${remaining.join(", ")}`) - : `${baseDescription}\nTavolo: ${remaining.join(", ")}`.trim(); - try { - await calendar.events.patch({ - calendarId: GOOGLE_CALENDAR_ID, - eventId: event.id, - requestBody: { - summary: updatedSummary, - description: updatedDescription, - }, - }); - } catch (err) { - console.error("[GOOGLE] Calendar event update failed:", err); - } - } - } +app.post("/debug/seed", async (req, res) => { + if (!dbAvailable()) { + return res.status(200).json({ ok: false, reason: "DATABASE_URL missing" }); } - - return { status: "ok", selection }; -} - -async function cancelCalendarEvent(session) { - if (!session?.calendarEventId) return null; - const calendar = buildCalendarClient(); - if (!calendar) return null; - const summary = session.calendarEventSummary || ""; - const summaryWithoutTable = summary.replace(/,\s*tav[^,]+/i, "").trim(); - const updatedSummary = summaryWithoutTable.startsWith("Annullata") - ? summaryWithoutTable - : `Annullata - ${summaryWithoutTable}`; - + const prisma = getPrismaClient(); try { - const result = await calendar.events.patch({ - calendarId: GOOGLE_CALENDAR_ID, - eventId: session.calendarEventId, - requestBody: { - summary: updatedSummary, - description: [ - `Nome: ${session.name || ""}`, - `Persone: ${session.people || ""}`, - `Note: ${session.specialRequestsRaw || "nessuna"}`, - `Preordine: ${session.preorderLabel || "nessuno"}`, - "Tavolo: ", - `Telefono: ${session.phone || "non fornito"}`, - ].join("\n"), - }, + const locale = await prisma.locale.upsert({ + where: { name: "TuttiBrilli" }, + update: {}, + create: { name: "TuttiBrilli" }, }); - if (session.dateISO) { - await upsertAvailabilityEvent(session.dateISO); - } - return result?.data || null; - } catch (err) { - console.error("[GOOGLE] Calendar cancel update failed:", err); - return null; - } -} - -async function createCalendarEvent(session) { - if (!GOOGLE_CALENDAR_ID) return null; - const calendar = buildCalendarClient(); - if (!calendar) return null; - if (!session?.dateISO || !session?.time24 || !session?.name || !session?.people) return null; - - const reservation = await reserveTableForSession(session, { commit: true }); - if (reservation.status === "closed") return { status: "closed" }; - if (reservation.status === "unavailable") return { status: "unavailable" }; - - const startDateTime = `${session.dateISO}T${session.time24}:00`; - const endDate = new Date(`${session.dateISO}T${session.time24}:00`); - endDate.setMinutes(endDate.getMinutes() + 120); - const endDateTime = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String( - endDate.getDate() - ).padStart(2, "0")}T${String(endDate.getHours()).padStart(2, "0")}:${String( - endDate.getMinutes() - ).padStart(2, "0")}:00`; - - const tableLabel = session.tableLocks?.length - ? session.tableLocks.join(" e ") - : session.tableDisplayId || "da assegnare"; - const event = { - summary: `Ore ${session.time24}, tav ${tableLabel}, ${session.name}, ${session.people} pax`, - description: [ - `Nome: ${session.name}`, - `Persone: ${session.people}`, - `Note: ${session.specialRequestsRaw || "nessuna"}`, - `Preordine: ${session.preorderLabel || "nessuno"}`, - `Tavolo: ${tableLabel}`, - `Telefono: ${session.phone || "non fornito"}`, - ].join("\n"), - start: { dateTime: startDateTime, timeZone: GOOGLE_CALENDAR_TZ }, - end: { dateTime: endDateTime, timeZone: GOOGLE_CALENDAR_TZ }, - }; - if (session.criticalReservation) { - event.colorId = CRITICAL_COLOR_ID; + return res.status(200).json({ ok: true, locale }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); } +}); +app.post("/debug/calendar-test", async (req, res) => { try { - const result = await calendar.events.insert({ - calendarId: GOOGLE_CALENDAR_ID, - requestBody: event, + const event = await createTestEvent({ + summary: "Test TuttiBrilli", + description: "Evento test creato da /debug/calendar-test", }); - const data = result?.data || null; - if (data?.id) { - session.calendarEventId = data.id; - session.calendarEventSummary = data.summary || event.summary; - } - await upsertAvailabilityEvent(session.dateISO); - return data; - } catch (err) { - console.error("[GOOGLE] Calendar insert failed:", err); - return null; + return res.status(200).json({ ok: true, htmlLink: event.htmlLink }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); } -} - -// ======================= ROUTES ======================= -app.get("/health", (req, res) => res.json({ ok: true })); +}); -app.post("/call/outbound", async (req, res) => { +app.post("/debug/whatsapp-test", async (req, res) => { + const { to, body } = req.body || {}; try { - const to = String(req.body?.to || "").trim(); - if (!to || !isValidPhoneE164(to)) { - return res.status(400).json({ ok: false, error: "Invalid 'to' number" }); - } - if (!twilioClient) { - return res.status(500).json({ ok: false, error: "Twilio not configured" }); - } - const fromNumber = requireTwilioVoiceFrom(); - const twimlUrl = BASE_URL ? `${BASE_URL}/twilio/voice/outbound` : "/twilio/voice/outbound"; - const call = await twilioClient.calls.create({ + const message = await sendWhatsAppMessage({ to, - from: fromNumber, - url: twimlUrl, - method: "POST", + body: body || "Test WhatsApp da TuttiBrilli", }); - return res.json({ ok: true, callSid: call.sid }); - } catch (err) { - console.error("[OUTBOUND] Error:", err); - if (err && err.message === "TWILIO_VOICE_FROM is not set") { - return res.status(500).json({ ok: false, error: "TWILIO_VOICE_FROM not set" }); - } - return res.status(500).json({ ok: false }); + return res.status(200).json({ ok: true, sid: message.sid }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); } }); -app.post("/twilio/voice/outbound", (req, res) => { +app.post("/debug/call-outbound", async (req, res) => { + const { to, from } = req.body || {}; try { - const vr = buildTwiml(); - vr.say( - { language: "it-IT", voice: "alice" }, - xmlEscape("Ciao! Ti chiamiamo da TuttiBrilli per un aggiornamento sulla tua prenotazione. Grazie.") - ); - vr.hangup(); - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); - } catch (err) { - console.error("[OUTBOUND_TWIML] Error:", err); - return res.status(500).send("Error"); + const call = await createOutboundCall({ to, from }); + return res.status(200).json({ ok: true, sid: call.sid }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); } }); -async function handleVoiceRequest(req, res) { - const callSid = req.body.CallSid || ""; - const speech = req.body.SpeechResult || ""; +app.post("/whatsapp/inbound", (req, res) => { + const from = req.body.From || "sconosciuto"; + const incomingMsg = req.body.Body || ""; + const reply = `Ciao! ✅ Messaggio ricevuto da ${from}. Hai scritto: "${incomingMsg}"`; + res.type("text/xml").status(200).send(createMessageResponse(reply)); +}); + +app.post("/voice", (req, res) => { + const callSid = req.body.CallSid; const session = getSession(callSid); - const vr = buildTwiml(); + session.step = "intent"; + session.attempts = 0; + session.data = { from: req.body.From, callSid }; + + const prompt = + "Benvenuto da TuttiBrilli. Vuoi fare una prenotazione o chiedere informazioni?"; + const twiml = createGatherResponse({ + action: "/voice/step", + prompt, + }); + res.type("text/xml").status(200).send(twiml); +}); - try { - if (!session) { - sayIt(vr, "Errore di sessione. Riprova tra poco."); - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); - } +app.post("/voice/step", async (req, res) => { + const callSid = req.body.CallSid; + const speechResult = normalizeSpeechText(req.body.SpeechResult || ""); + const session = getSession(callSid); + const { data } = session; - const emptySpeech = !normalizeText(speech); + if (!speechResult) { + incrementAttempts(session); + if (session.attempts >= MAX_ATTEMPTS) { + const twiml = createForwardingTwiml(); + return res.type("text/xml").status(200).send(twiml); + } + const twiml = createGatherResponse({ + action: "/voice/step", + prompt: "Non ho capito, puoi ripetere?", + }); + return res.type("text/xml").status(200).send(twiml); + } - if (!emptySpeech && isCancelCommand(speech)) { - const canceled = await cancelCalendarEvent(session); - if (canceled) { - session.step = 1; - gatherSpeech(vr, "Ho annullato la prenotazione. Vuoi prenotare di nuovo?"); - } else if (canForwardToHuman()) { - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(forwardToHumanTwiml()); - } else { - session.step = 1; - gatherSpeech(vr, "Ok, mi occupo di annullare la prenotazione. Vuoi fare una nuova richiesta?"); + switch (session.step) { + case "intent": { + if (speechResult.includes("informaz")) { + const twiml = createSayResponse( + "Per informazioni puoi visitare il nostro sito. Arrivederci." + ); + return res.type("text/xml").status(200).send(twiml); } - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); + session.step = "name"; + resetAttempts(session); + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Perfetto. Qual è il tuo nome?", + }) + ); } - - if (!emptySpeech && isBackCommand(speech)) { - resetRetries(session); - goBack(session); - promptForStep(vr, session); - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); + case "name": { + data.name = speechResult; + session.step = "date"; + resetAttempts(session); + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Per quale data? puoi dire oggi, domani o una data come 12-10.", + }) + ); } - - switch (session.step) { - case 1: { - if (emptySpeech) { - gatherSpeech(vr, t("step1_welcome_name.main")); - break; - } - session.name = speech.trim().slice(0, 60); - resetRetries(session); - session.step = 2; - gatherSpeech(vr, t("step2_confirm_name_ask_date.main", { name: session.name })); - break; - } - - case 2: { - if (emptySpeech) { - gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); - break; - } - const dateISO = parseDateIT(speech); - if (!dateISO) { - gatherSpeech(vr, t("step3_confirm_date_ask_time.error")); - break; - } - session.dateISO = dateISO; - resetRetries(session); - session.step = 3; - session.dateLabel = formatDateLabel(dateISO); - gatherSpeech(vr, "Perfetto. In quante persone siete?"); - break; - } - - case 3: { - if (emptySpeech) { - gatherSpeech(vr, t("step5_party_size_ask_notes.error")); - break; + case "date": { + const dateISO = guessDateFromSpeech(speechResult); + if (!dateISO) { + incrementAttempts(session); + if (session.attempts >= MAX_ATTEMPTS) { + const twiml = createForwardingTwiml(); + return res.type("text/xml").status(200).send(twiml); } - const people = parsePeopleIT(speech); - if (!people) { - gatherSpeech(vr, t("step5_party_size_ask_notes.error")); - break; - } - session.people = people; - const events = await listCalendarEvents(session.dateISO); - const date = new Date(`${session.dateISO}T00:00:00`); - const isHoliday = HOLIDAYS_SET.has(session.dateISO); - const isClosedDay = date.getDay() === OPENING.closedDay; - if (isHoliday || isClosedDay || isDateClosedByCalendar(events)) { - session.step = 2; - gatherSpeech(vr, "Mi dispiace, il locale risulta chiuso quel giorno. Vuoi scegliere un'altra data?"); - break; - } - resetRetries(session); - session.step = 4; - gatherSpeech(vr, t("step3_confirm_date_ask_time.main", { dateLabel: session.dateLabel })); - break; + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Data non valida, ripeti con giorno e mese.", + }) + ); } - - case 4: { - if (emptySpeech) { - gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); - break; - } - const time24 = parseTimeIT(speech); - if (!time24) { - gatherSpeech(vr, t("step4_confirm_time_ask_party_size.error")); - break; + data.dateISO = dateISO; + session.step = "time"; + resetAttempts(session); + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "A che ora?", + }) + ); + } + case "time": { + const time24 = guessTimeFromSpeech(speechResult); + if (!time24) { + incrementAttempts(session); + if (session.attempts >= MAX_ATTEMPTS) { + const twiml = createForwardingTwiml(); + return res.type("text/xml").status(200).send(twiml); } - session.time24 = time24; - resetRetries(session); - session.step = 5; - gatherSpeech(vr, t("step5_party_size_ask_notes.main", { partySize: session.people })); - break; + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Ora non valida, ad esempio diciannove e trenta.", + }) + ); } - - case 5: { - session.specialRequestsRaw = emptySpeech ? "nessuna" : speech.trim().slice(0, 200); - resetRetries(session); - session.step = 6; - gatherSpeech( - vr, - "Vuoi preordinare qualcosa dal menù? Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." + data.time24 = time24; + session.step = "people"; + resetAttempts(session); + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Per quante persone?", + }) ); - break; - } - - case 6: { - if (emptySpeech) { - promptForStep(vr, session); - break; + } + case "people": { + const people = extractNumberFromSpeech(speechResult); + if (!people) { + incrementAttempts(session); + if (session.attempts >= MAX_ATTEMPTS) { + const twiml = createForwardingTwiml(); + return res.type("text/xml").status(200).send(twiml); } - const normalized = normalizeText(speech); - if (normalized.includes("nessuno") || normalized.includes("niente") || normalized.includes("no")) { - session.preorderChoiceKey = null; - session.preorderLabel = "nessuno"; - } else { - const option = PREORDER_OPTIONS.find( - (o) => normalized.includes(o.label.toLowerCase()) || normalized.includes(o.key.replace(/_/g, " ")) + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Numero non valido, ripeti quante persone.", + }) ); - if (option) { - session.preorderChoiceKey = option.key; - session.preorderLabel = option.label; - } else { - gatherSpeech( - vr, - "Non ho capito il preordine. Puoi dire: cena, apericena, dopocena, piatto apericena, oppure piatto apericena promo. Se non vuoi, dì nessuno." - ); - break; - } - } - const availability = await reserveTableForSession(session, { commit: false }); - if (availability.status === "closed") { - session.step = 2; - gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); - break; - } - if (availability.status === "unavailable") { - session.step = 4; - gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); - break; - } - if (availability.status === "needs_split" || availability.status === "needs_outside" || session.outsideRequired) { - session.criticalReservation = true; - session.step = 8; - gatherSpeech( - vr, - "La prenotazione è stata effettuata con criticità. Ti richiamerà un operatore. Intanto mi lasci un numero di telefono? Se è italiano, aggiungo io il +39." + } + data.people = people; + session.step = "whatsapp"; + resetAttempts(session); + if ((data.from || "").startsWith("+39")) { + data.whatsapp = data.from; + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: + "Vuoi ricevere la conferma su WhatsApp a questo numero? Rispondi sì o no.", + }) ); - break; - } - resetRetries(session); - session.step = 8; - gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); - break; } - - case 7: { - const confirmation = parseYesNo(speech); - if (confirmation === null) { - if (session.outsideRequired) { - gatherSpeech( - vr, - "Ti ricordo che la sala esterna è senza copertura e con maltempo non posso garantire un tavolo all'interno. Confermi?" - ); - } else { - gatherSpeech(vr, "Posso sistemarvi in tavoli separati? Se preferisci, ti passo un operatore."); - } - break; - } - if (!confirmation) { - if (canForwardToHuman()) { - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(forwardToHumanTwiml()); - } - sayIt(vr, t("step9_fallback_transfer_operator.main")); - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Qual è il tuo numero WhatsApp?", + }) + ); + } + case "whatsapp": { + if ((data.from || "").startsWith("+39") && !data.confirmedWhatsApp) { + if (speechResult.includes("si") || speechResult.includes("sì")) { + data.confirmedWhatsApp = true; + } else { + data.whatsapp = null; } - resetRetries(session); - session.step = 8; - gatherSpeech(vr, "Perfetto. Mi lasci un numero di telefono? Se è italiano, aggiungo io il +39."); - break; } - - case 8: { - if (emptySpeech) { - gatherSpeech(vr, "Scusami, non ho sentito il numero. Me lo ripeti?"); - break; - } - const phone = parsePhoneNumber(speech); - if (!phone) { - gatherSpeech(vr, "Scusami, non ho capito il numero. Puoi ripeterlo?"); - break; - } - session.phone = phone; - resetRetries(session); - if (session.criticalReservation) { - const calendarEvent = await createCalendarEvent(session); - const summary = `Data ${session.dateLabel || session.dateISO || ""}, ore ${session.time24 || ""}, ${session.people || ""} persone.`; - await notifyCriticalReservation(session.phone, summary); - await sendCriticalReservationSms(summary); - if (calendarEvent?.status === "closed") { - session.step = 2; - session.criticalReservation = false; - gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); - break; - } - if (calendarEvent?.status === "unavailable") { - session.step = 4; - session.criticalReservation = false; - gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); - break; + if (!data.confirmedWhatsApp && !data.whatsapp) { + const phone = speechResult.replace(/\s/g, ""); + if (!phone.startsWith("+")) { + incrementAttempts(session); + if (session.attempts >= MAX_ATTEMPTS) { + const twiml = createForwardingTwiml(); + return res.type("text/xml").status(200).send(twiml); } - resetRetries(session); - session.step = 1; - session.criticalReservation = false; - gatherSpeech( - vr, - "La prenotazione è stata effettuata. Verrai richiamato da un operatore per confermare i dettagli." - ); - break; + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Inserisci il numero con prefisso, ad esempio +39...", + }) + ); } - session.step = 9; - gatherSpeech( - vr, - t("step8_summary_confirm.main", { - name: session.name || "", - dateLabel: session.dateLabel || "", - time: session.time24 || "", - partySize: session.people || "", - }) - ); - break; + data.whatsapp = phone; } - - case 9: { - const confirmation = parseYesNo(speech); - if (confirmation === null) { - gatherSpeech( - vr, - t("step8_summary_confirm.short", { - dateLabel: session.dateLabel || "", - time: session.time24 || "", - partySize: session.people || "", + if ((data.from || "").startsWith("+39") && !data.confirmedWhatsApp && !data.whatsapp) { + return res + .type("text/xml") + .status(200) + .send( + createGatherResponse({ + action: "/voice/step", + prompt: "Qual è il tuo numero WhatsApp?", }) ); - break; - } - if (!confirmation) { - resetRetries(session); - goBack(session); - promptForStep(vr, session); - break; - } - const calendarEvent = await createCalendarEvent(session); - if (calendarEvent?.status === "closed") { - session.step = 2; - gatherSpeech(vr, "Mi dispiace, risulta che quel giorno il locale è chiuso. Vuoi scegliere un'altra data?"); - break; - } - if (calendarEvent?.status === "unavailable") { - session.step = 4; - gatherSpeech(vr, "Mi dispiace, a quell'orario non ci sono tavoli disponibili. Vuoi provare un altro orario?"); - break; - } - if (!calendarEvent && canForwardToHuman()) { - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(forwardToHumanTwiml()); - } - resetRetries(session); - session.step = 1; - gatherSpeech(vr, t("step9_success.main")); - break; } - default: { - if (canForwardToHuman()) { - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(forwardToHumanTwiml()); + try { + const booking = await createBookingRecord(data); + const event = await createBookingEvent({ + booking, + bookingKey: booking.bookingKey, + }); + await markBookingCalendar(booking.id, event.id); + if (data.whatsapp) { + await sendWhatsAppMessage({ + to: `whatsapp:${data.whatsapp}`, + body: `Prenotazione confermata per ${booking.name} il ${booking.dateISO} alle ${booking.time24} per ${booking.people} persone.`, + }); } - resetRetries(session); - session.step = 1; - gatherSpeech(vr, t("step1_welcome_name.short")); - break; + bookingSessions.delete(callSid); + const twiml = createSayResponse( + "Perfetto, prenotazione registrata. Ti invieremo conferma su WhatsApp." + ); + return res.type("text/xml").status(200).send(twiml); + } catch (error) { + console.error("Booking flow error", error); + const twiml = createSayResponse( + "C'è un problema con il calendario, ti ricontatteremo." + ); + return res.type("text/xml").status(200).send(twiml); } } - - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); - } catch (err) { - console.error("[VOICE] Error:", err); - if (canForwardToHuman()) { - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(forwardToHumanTwiml()); + default: { + const twiml = createSayResponse("Sessione terminata."); + return res.type("text/xml").status(200).send(twiml); } - sayIt(vr, t("step9_fallback_transfer_operator.main")); - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); } -} - -app.all("/twilio/voice", async (req, res) => { - console.log(`[VOICE] ${req.method} /twilio/voice`); - const payload = req.method === "POST" ? req.body : req.query; - if (payload && Object.keys(payload).length > 0) { - req.body = payload; - return handleVoiceRequest(req, res); - } - const vr = buildTwiml(); - vr.say( - { language: "it-IT", voice: "alice" }, - xmlEscape("Test Twilio Voice: endpoint attivo.") - ); - res.set("Content-Type", "text/xml; charset=utf-8"); - return res.send(vr.toString()); -}); - -app.all("/voice", (req, res) => { - const targetUrl = BASE_URL ? `${BASE_URL}/twilio/voice` : "/twilio/voice"; - return res.redirect(307, targetUrl); }); -// NOTA: /finalize lo rimettiamo dopo, quando confermi che non cade più allo step 5. -// (non serve per risolvere il crash delle intolleranze) +async function createBookingRecord(data) { + if (!dbAvailable()) { + return { + id: "no-db", + bookingKey: data.callSid || `call-${Date.now()}`, + name: data.name, + dateISO: data.dateISO, + time24: data.time24, + people: data.people, + }; + } + const prisma = getPrismaClient(); + const locale = await prisma.locale.upsert({ + where: { name: "TuttiBrilli" }, + update: {}, + create: { name: "TuttiBrilli" }, + }); + const businessDay = await prisma.businessDay.upsert({ + where: { + localeId_dateISO: { + localeId: locale.id, + dateISO: data.dateISO, + }, + }, + update: {}, + create: { + localeId: locale.id, + dateISO: data.dateISO, + }, + }); + const bookingKey = data.callSid || `call-${Date.now()}`; + const booking = await prisma.booking.create({ + data: { + bookingKey, + name: data.name, + dateISO: data.dateISO, + time24: data.time24, + people: data.people, + status: "PENDING", + phone: data.from, + whatsapp: data.whatsapp, + localeId: locale.id, + businessDayId: businessDay.id, + }, + }); + return booking; +} -app.get("/", (req, res) => { - res.send("OK - backend running"); -}); +async function markBookingCalendar(bookingId, calendarEventId) { + if (!dbAvailable() || bookingId === "no-db") { + return; + } + const prisma = getPrismaClient(); + await prisma.booking.update({ + where: { id: bookingId }, + data: { calendarEventId, status: "CONFIRMED" }, + }); +} -app.listen(PORT, () => { - console.log(`Voice assistant running on port ${PORT}`); +const port = process.env.PORT || 3000; +app.listen(port, () => { + console.log(`Server running on port ${port}`); }); From 48db4667f54ca773d85d3059f0103e98ae6a8e55 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:38:34 +0100 Subject: [PATCH 070/143] Update package.json --- package.json | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 9b593a511b..71bf57992e 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,23 @@ { - "name": "ai-backoffice-tuttibrilli", + "name": "express-hello-world", "version": "1.0.0", - "description": "Assistente vocale AI per TuttiBrilli (Twilio + Google Calendar)", + "description": "Express + Prisma + Twilio + Google Calendar backend for Render", "main": "app.js", + "repository": "https://github.com/render-examples/express-hello-world", + "author": "Render Developers", + "license": "MIT", "scripts": { "start": "node app.js", - "build": "prisma generate" - }, - "engines": { - "node": ">=18" + "postinstall": "prisma generate", + "migrate": "prisma migrate deploy", + "prisma:generate": "prisma generate" }, "dependencies": { - "@prisma/client": "^5.22.0", - "express": "^4.19.2", - "googleapis": "^169.0.0", - "twilio": "^5.11.1" - }, - "devDependencies": { - "prisma": "^5.22.0" + "@prisma/client": "^5.18.0", + "dotenv": "^16.4.5", + "express": "^5.0.0", + "googleapis": "^140.0.1", + "prisma": "^5.18.0", + "twilio": "^5.2.2" } } From 38821c66007b39bd9ef015df95023bb62ab87112 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:40:19 +0100 Subject: [PATCH 071/143] Create twilio.js --- lib/twilio.js | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 lib/twilio.js diff --git a/lib/twilio.js b/lib/twilio.js new file mode 100644 index 0000000000..246af3f1ec --- /dev/null +++ b/lib/twilio.js @@ -0,0 +1,139 @@ +const twilio = require("twilio"); + +function xmlEscape(input) { + return String(input) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + +function twiml(inner) { + return `\n\n${inner}\n`; +} + +function createSayResponse(text) { + return twiml(`${xmlEscape(text)}`); +} + +function createMessageResponse(text) { + return twiml(`${xmlEscape(text)}`); +} + +function createGatherResponse({ action, prompt }) { + return twiml( + `\n` + + `${xmlEscape(prompt)}\n` + + `` + ); +} + +function createDialResponse(number) { + return twiml( + `Ti metto in contatto con un operatore.\n${xmlEscape( + number + )}` + ); +} + +function normalizeSpeechText(text) { + return String(text || "").trim().toLowerCase(); +} + +function guessDateFromSpeech(text) { + const now = new Date(); + if (text.includes("oggi")) { + return now.toISOString().slice(0, 10); + } + if (text.includes("domani")) { + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + return tomorrow.toISOString().slice(0, 10); + } + const match = text.match(/(\d{1,2})[\/-](\d{1,2})/); + if (match) { + const day = Number(match[1]); + const month = Number(match[2]); + const year = now.getFullYear(); + const date = new Date(Date.UTC(year, month - 1, day)); + if (!Number.isNaN(date.getTime())) { + return date.toISOString().slice(0, 10); + } + } + return null; +} + +function guessTimeFromSpeech(text) { + const match = text.match(/(\d{1,2})(?:[:\s](\d{1,2}))?/); + if (!match) { + return null; + } + let hour = Number(match[1]); + let minute = match[2] ? Number(match[2]) : 0; + if (text.includes("mezza")) { + minute = 30; + } + if (hour > 23 || minute > 59) { + return null; + } + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +} + +function extractNumberFromSpeech(text) { + const match = text.match(/\d+/); + if (!match) { + return null; + } + const value = Number(match[0]); + return Number.isFinite(value) ? value : null; +} + +function getTwilioClient() { + if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) { + throw new Error("Twilio credentials missing"); + } + return twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); +} + +async function sendWhatsAppMessage({ to, body }) { + if (!to) { + throw new Error("WhatsApp destination missing"); + } + const from = process.env.TWILIO_WHATSAPP_FROM; + if (!from) { + throw new Error("TWILIO_WHATSAPP_FROM missing"); + } + const client = getTwilioClient(); + return client.messages.create({ to, from, body }); +} + +async function createOutboundCall({ to, from }) { + const client = getTwilioClient(); + const fromNumber = from || process.env.TWILIO_VOICE_FROM; + if (!fromNumber) { + throw new Error("TWILIO_VOICE_FROM missing"); + } + const baseUrl = process.env.BASE_URL || ""; + if (!baseUrl) { + throw new Error("BASE_URL missing"); + } + return client.calls.create({ + to, + from: fromNumber, + url: `${baseUrl}/voice`, + }); +} + +module.exports = { + createSayResponse, + createMessageResponse, + createGatherResponse, + createDialResponse, + normalizeSpeechText, + guessDateFromSpeech, + guessTimeFromSpeech, + extractNumberFromSpeech, + sendWhatsAppMessage, + createOutboundCall, +}; From 1a93d059db06ebe4c52da70d26c5d7cae90106cb Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:41:33 +0100 Subject: [PATCH 072/143] Create calendar.js --- lib/lib/calendar.js | 90 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 lib/lib/calendar.js diff --git a/lib/lib/calendar.js b/lib/lib/calendar.js new file mode 100644 index 0000000000..cc33fafa1a --- /dev/null +++ b/lib/lib/calendar.js @@ -0,0 +1,90 @@ +const { google } = require("googleapis"); + +function getServiceAccount() { + const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64; + if (!raw) { + throw new Error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing"); + } + const json = Buffer.from(raw, "base64").toString("utf-8"); + return JSON.parse(json); +} + +function getCalendarClient() { + const credentials = getServiceAccount(); + const auth = new google.auth.JWT({ + email: credentials.client_email, + key: credentials.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + }); + return google.calendar({ version: "v3", auth }); +} + +async function findExistingEvent(calendar, calendarId, bookingKey) { + const response = await calendar.events.list({ + calendarId, + privateExtendedProperty: `bookingKey=${bookingKey}`, + maxResults: 1, + singleEvents: true, + }); + const items = response.data.items || []; + return items.length ? items[0] : null; +} + +async function createBookingEvent({ booking, bookingKey }) { + const calendarId = process.env.GOOGLE_CALENDAR_ID; + if (!calendarId) { + throw new Error("GOOGLE_CALENDAR_ID missing"); + } + const calendar = getCalendarClient(); + const existing = await findExistingEvent(calendar, calendarId, bookingKey); + if (existing) { + return existing; + } + + const duration = Number(process.env.DEFAULT_EVENT_DURATION_MINUTES || 90); + const start = new Date(`${booking.dateISO}T${booking.time24}:00+02:00`); + const end = new Date(start.getTime() + duration * 60000); + + const event = { + summary: `Prenotazione ${booking.name}`, + description: `Prenotazione ${booking.name} (${booking.people} persone). BookingKey: ${bookingKey}`, + start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, + end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, + extendedProperties: { + private: { + bookingKey, + }, + }, + }; + + const response = await calendar.events.insert({ + calendarId, + requestBody: event, + }); + return response.data; +} + +async function createTestEvent({ summary, description }) { + const calendarId = process.env.GOOGLE_CALENDAR_ID; + if (!calendarId) { + throw new Error("GOOGLE_CALENDAR_ID missing"); + } + const calendar = getCalendarClient(); + const start = new Date(); + const end = new Date(start.getTime() + 30 * 60000); + const response = await calendar.events.insert({ + calendarId, + requestBody: { + summary, + description, + start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, + end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, + }, + }); + return response.data; +} + +module.exports = { + createBookingEvent, + createTestEvent, +}; From 4b9a9fd323d9024a691ba2dbe5c376e9e30f850a Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:43:23 +0100 Subject: [PATCH 073/143] Update schema.prisma --- prisma/schema.prisma | 169 ++++++++++++++++--------------------------- 1 file changed, 64 insertions(+), 105 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc130165b4..f896dfb2a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,140 +1,99 @@ -// ============================== -// DATASOURCE & GENERATOR -// ============================== +generator client { + provider = "prisma-client-js" +} + datasource db { provider = "postgresql" url = env("DATABASE_URL") } -generator client { - provider = "prisma-client-js" -} - -// ============================== -// ENUM -// ============================== -enum Role { +enum UserRole { OWNER - ADMIN - SALA - CUCINA + STAFF + CUSTOMER } enum BookingStatus { - REQUESTED + PENDING CONFIRMED CANCELLED } -enum SaleCategory { - VINO - FOOD - BIBITE -} - -// ============================== -// MODELS -// ============================== - model Locale { - id String @id @default(uuid()) - name String - createdAt DateTime @default(now()) - - users User[] - businessDays BusinessDay[] - bookings Booking[] + id String @id @default(cuid()) + name String @unique + createdAt DateTime @default(now()) + users User[] + days BusinessDay[] + bookings Booking[] + presences Presence[] + sales Sale[] + ratings ServiceRating[] } model User { - id String @id @default(uuid()) - email String @unique - passwordHash String - role Role - isActive Boolean @default(true) - - localeId String - locale Locale @relation(fields: [localeId], references: [id]) - - createdAt DateTime @default(now()) + id String @id @default(cuid()) + name String + role UserRole @default(STAFF) + localeId String + locale Locale @relation(fields: [localeId], references: [id]) + createdAt DateTime @default(now()) + bookings Booking[] @relation("BookingCreatedBy") } model BusinessDay { - id String @id @default(uuid()) - localeId String - locale Locale @relation(fields: [localeId], references: [id]) - - dateISO String // es: 2026-03-18 - isClosed Boolean @default(false) - notes String? - - bookings Booking[] - sales Sale[] - presences Presence[] - rating ServiceRating? - - createdAt DateTime @default(now()) + id String @id @default(cuid()) + dateISO String + localeId String + locale Locale @relation(fields: [localeId], references: [id]) + bookings Booking[] @@unique([localeId, dateISO]) } model Booking { - id String @id @default(uuid()) - localeId String - businessDayId String - - locale Locale @relation(fields: [localeId], references: [id]) - businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) - - name String - time24 String // HH:MM - people Int - status BookingStatus @default(REQUESTED) - - phone String? - whatsapp String? - notes String? - - calendarEventId String? // Google Calendar event ID - createdBy String // user | assistant - - createdAt DateTime @default(now()) + id String @id @default(cuid()) + bookingKey String @unique + name String + dateISO String + time24 String + people Int + status BookingStatus @default(PENDING) + phone String? + whatsapp String? + calendarEventId String? + localeId String + businessDayId String + createdById String? + createdAt DateTime @default(now()) + + locale Locale @relation(fields: [localeId], references: [id]) + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) + createdBy User? @relation("BookingCreatedBy", fields: [createdById], references: [id]) } model Presence { - id String @id @default(uuid()) - businessDayId String - businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) - - timeSlot String // es: 19-20 - expectedPeople Int - actualPeople Int - - createdAt DateTime @default(now()) + id String @id @default(cuid()) + localeId String + userId String + dateISO String + locale Locale @relation(fields: [localeId], references: [id]) + user User @relation(fields: [userId], references: [id]) } model Sale { - id String @id @default(uuid()) - businessDayId String - businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) - - category SaleCategory - productName String - quantity Int - totalAmount Float - - createdAt DateTime @default(now()) + id String @id @default(cuid()) + localeId String + amount Int + dateISO String + locale Locale @relation(fields: [localeId], references: [id]) } model ServiceRating { - id String @id @default(uuid()) - businessDayId String @unique - businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) - - customerSatisfaction Int // 1–5 - serviceEfficiency Int // 1–5 - kitchenEfficiency Int // 1–5 - notes String? - - createdAt DateTime @default(now()) + id String @id @default(cuid()) + localeId String + rating Int + comment String? + dateISO String + locale Locale @relation(fields: [localeId], references: [id]) } From a0c3fa97759c88d53f644e3245276699d46be364 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:48:35 +0100 Subject: [PATCH 074/143] Create .env.example --- .env.example | 1 + 1 file changed, 1 insertion(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ + From e3ed538e4891e361ae64a2093c862c9bcfd0fff6 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:49:55 +0100 Subject: [PATCH 075/143] Create render.yaml --- render.yaml | 1 + 1 file changed, 1 insertion(+) create mode 100644 render.yaml diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/render.yaml @@ -0,0 +1 @@ + From 55ad0389542bc61ad7d09b1fe562982889b286a9 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:50:22 +0100 Subject: [PATCH 076/143] Create db.js --- lib/db.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 lib/db.js diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lib/db.js @@ -0,0 +1 @@ + From 1c037dd7cd37b33017768b7654fa34a86cf0258a Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:50:57 +0100 Subject: [PATCH 077/143] Create migrate-if-configured.js --- scripts/migrate-if-configured.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 scripts/migrate-if-configured.js diff --git a/scripts/migrate-if-configured.js b/scripts/migrate-if-configured.js new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/scripts/migrate-if-configured.js @@ -0,0 +1 @@ + From e8d266201a4672bc88d2cc9f9ad4db6cde59fe7e Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 22:54:06 +0100 Subject: [PATCH 078/143] Delete .env.example --- .env.example | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 8b13789179..0000000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ - From 2a6715b3d10fd52206575514afed8df92cb6aad4 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:02:33 +0100 Subject: [PATCH 079/143] Create .env.example --- .env.example | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..6cc3a13068 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ + (cd "$(git rev-parse --show-toplevel)" && git apply --3way <<'EOF' +diff --git a/.env.example b/.env.example +new file mode 100644 +index 0000000000000000000000000000000000000000..d4623c81eca7e19b4b6a605c3eb54e20cde32064 +--- /dev/null ++++ b/.env.example +@@ -0,0 +1,12 @@ ++PORT=3000 ++BASE_URL=https://your-service.onrender.com ++DATABASE_URL=postgresql://user:password@host:5432/dbname ++GOOGLE_SERVICE_ACCOUNT_JSON_B64=base64-encoded-json ++GOOGLE_CALENDAR_ID=your-calendar-id@group.calendar.google.com ++DEFAULT_EVENT_DURATION_MINUTES=90 ++TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ++TWILIO_AUTH_TOKEN=your_auth_token ++TWILIO_WHATSAPP_FROM=whatsapp:+14155238886 ++TWILIO_VOICE_FROM=+1234567890 ++HUMAN_FORWARD_TO=+391234567890 ++FORWARDING_ENABLED=true + +EOF +) From 078b5241e77f1547ea7be3f1533e8b8ac477df3f Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:07:00 +0100 Subject: [PATCH 080/143] Delete lib/db.js --- lib/db.js | 1 - 1 file changed, 1 deletion(-) delete mode 100644 lib/db.js diff --git a/lib/db.js b/lib/db.js deleted file mode 100644 index 8b13789179..0000000000 --- a/lib/db.js +++ /dev/null @@ -1 +0,0 @@ - From d55b0ed7baa70e05ecfac177686af744f1cf55b1 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:07:18 +0100 Subject: [PATCH 081/143] Delete scripts/migrate-if-configured.js --- scripts/migrate-if-configured.js | 1 - 1 file changed, 1 deletion(-) delete mode 100644 scripts/migrate-if-configured.js diff --git a/scripts/migrate-if-configured.js b/scripts/migrate-if-configured.js deleted file mode 100644 index 8b13789179..0000000000 --- a/scripts/migrate-if-configured.js +++ /dev/null @@ -1 +0,0 @@ - From 5233626a066e74e4fc138dc85ab9cb38baa11b03 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:09:46 +0100 Subject: [PATCH 082/143] Delete lib/lib/calendar.js --- lib/lib/calendar.js | 90 --------------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 lib/lib/calendar.js diff --git a/lib/lib/calendar.js b/lib/lib/calendar.js deleted file mode 100644 index cc33fafa1a..0000000000 --- a/lib/lib/calendar.js +++ /dev/null @@ -1,90 +0,0 @@ -const { google } = require("googleapis"); - -function getServiceAccount() { - const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64; - if (!raw) { - throw new Error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing"); - } - const json = Buffer.from(raw, "base64").toString("utf-8"); - return JSON.parse(json); -} - -function getCalendarClient() { - const credentials = getServiceAccount(); - const auth = new google.auth.JWT({ - email: credentials.client_email, - key: credentials.private_key, - scopes: ["https://www.googleapis.com/auth/calendar"], - }); - return google.calendar({ version: "v3", auth }); -} - -async function findExistingEvent(calendar, calendarId, bookingKey) { - const response = await calendar.events.list({ - calendarId, - privateExtendedProperty: `bookingKey=${bookingKey}`, - maxResults: 1, - singleEvents: true, - }); - const items = response.data.items || []; - return items.length ? items[0] : null; -} - -async function createBookingEvent({ booking, bookingKey }) { - const calendarId = process.env.GOOGLE_CALENDAR_ID; - if (!calendarId) { - throw new Error("GOOGLE_CALENDAR_ID missing"); - } - const calendar = getCalendarClient(); - const existing = await findExistingEvent(calendar, calendarId, bookingKey); - if (existing) { - return existing; - } - - const duration = Number(process.env.DEFAULT_EVENT_DURATION_MINUTES || 90); - const start = new Date(`${booking.dateISO}T${booking.time24}:00+02:00`); - const end = new Date(start.getTime() + duration * 60000); - - const event = { - summary: `Prenotazione ${booking.name}`, - description: `Prenotazione ${booking.name} (${booking.people} persone). BookingKey: ${bookingKey}`, - start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, - end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, - extendedProperties: { - private: { - bookingKey, - }, - }, - }; - - const response = await calendar.events.insert({ - calendarId, - requestBody: event, - }); - return response.data; -} - -async function createTestEvent({ summary, description }) { - const calendarId = process.env.GOOGLE_CALENDAR_ID; - if (!calendarId) { - throw new Error("GOOGLE_CALENDAR_ID missing"); - } - const calendar = getCalendarClient(); - const start = new Date(); - const end = new Date(start.getTime() + 30 * 60000); - const response = await calendar.events.insert({ - calendarId, - requestBody: { - summary, - description, - start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, - end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, - }, - }); - return response.data; -} - -module.exports = { - createBookingEvent, - createTestEvent, -}; From f52a59bed97c5929afea5f1e6d6ecee5701b1001 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:10:55 +0100 Subject: [PATCH 083/143] Create calendar.js --- lib/calendar.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 lib/calendar.js diff --git a/lib/calendar.js b/lib/calendar.js new file mode 100644 index 0000000000..cc33fafa1a --- /dev/null +++ b/lib/calendar.js @@ -0,0 +1,90 @@ +const { google } = require("googleapis"); + +function getServiceAccount() { + const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64; + if (!raw) { + throw new Error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing"); + } + const json = Buffer.from(raw, "base64").toString("utf-8"); + return JSON.parse(json); +} + +function getCalendarClient() { + const credentials = getServiceAccount(); + const auth = new google.auth.JWT({ + email: credentials.client_email, + key: credentials.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + }); + return google.calendar({ version: "v3", auth }); +} + +async function findExistingEvent(calendar, calendarId, bookingKey) { + const response = await calendar.events.list({ + calendarId, + privateExtendedProperty: `bookingKey=${bookingKey}`, + maxResults: 1, + singleEvents: true, + }); + const items = response.data.items || []; + return items.length ? items[0] : null; +} + +async function createBookingEvent({ booking, bookingKey }) { + const calendarId = process.env.GOOGLE_CALENDAR_ID; + if (!calendarId) { + throw new Error("GOOGLE_CALENDAR_ID missing"); + } + const calendar = getCalendarClient(); + const existing = await findExistingEvent(calendar, calendarId, bookingKey); + if (existing) { + return existing; + } + + const duration = Number(process.env.DEFAULT_EVENT_DURATION_MINUTES || 90); + const start = new Date(`${booking.dateISO}T${booking.time24}:00+02:00`); + const end = new Date(start.getTime() + duration * 60000); + + const event = { + summary: `Prenotazione ${booking.name}`, + description: `Prenotazione ${booking.name} (${booking.people} persone). BookingKey: ${bookingKey}`, + start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, + end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, + extendedProperties: { + private: { + bookingKey, + }, + }, + }; + + const response = await calendar.events.insert({ + calendarId, + requestBody: event, + }); + return response.data; +} + +async function createTestEvent({ summary, description }) { + const calendarId = process.env.GOOGLE_CALENDAR_ID; + if (!calendarId) { + throw new Error("GOOGLE_CALENDAR_ID missing"); + } + const calendar = getCalendarClient(); + const start = new Date(); + const end = new Date(start.getTime() + 30 * 60000); + const response = await calendar.events.insert({ + calendarId, + requestBody: { + summary, + description, + start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, + end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, + }, + }); + return response.data; +} + +module.exports = { + createBookingEvent, + createTestEvent, +}; From ca2f7c3a2566065e2e2753c03642b06a3c211131 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:27:44 +0100 Subject: [PATCH 084/143] Update app.js --- app.js | 579 +++++++++++++++++++-------------------------------------- 1 file changed, 196 insertions(+), 383 deletions(-) diff --git a/app.js b/app.js index 9dcd04aff1..d47be25fc2 100644 --- a/app.js +++ b/app.js @@ -1,455 +1,268 @@ -const express = require("express"); -const dotenv = require("dotenv"); -const { - createGatherResponse, - createSayResponse, - createMessageResponse, - normalizeSpeechText, - guessDateFromSpeech, - guessTimeFromSpeech, - extractNumberFromSpeech, - createDialResponse, - sendWhatsAppMessage, - createOutboundCall, -} = require("./lib/twilio"); -const { getPrismaClient, dbAvailable } = require("./lib/db"); -const { createBookingEvent, createTestEvent } = require("./lib/calendar"); +const express = require('express'); +const dotenv = require('dotenv'); +const { getPrismaClient, ensureDefaultLocale, upsertBusinessDay } = require('./lib/db'); +const { createBookingEvent } = require('./lib/calendar'); +const { sendWhatsAppMessage, createOutboundCall } = require('./lib/twilio'); +const { twiml: TwilioTwiml } = require('twilio'); dotenv.config(); const app = express(); -app.use(express.urlencoded({ extended: false })); app.use(express.json()); +app.use(express.urlencoded({ extended: false })); + +const REQUIRED_ENV = [ + 'DATABASE_URL', + 'GOOGLE_SERVICE_ACCOUNT_JSON_B64', + 'GOOGLE_CALENDAR_ID', + 'TWILIO_ACCOUNT_SID', + 'TWILIO_AUTH_TOKEN', + 'TWILIO_WHATSAPP_FROM', + 'BASE_URL', + 'FORWARDING_ENABLED', + 'HUMAN_FORWARD_TO' +]; -const bookingSessions = new Map(); -const MAX_ATTEMPTS = 3; +const sessions = new Map(); +const MAX_RETRIES = 3; +const VOICE_STEPS = [ + { key: 'name', prompt: 'Ciao! Come ti chiami?' }, + { key: 'date', prompt: 'Per quale data vuoi prenotare?' }, + { key: 'time', prompt: 'A che ora?' }, + { key: 'people', prompt: 'Per quante persone?' }, + { key: 'whatsapp', prompt: 'Qual è il tuo numero WhatsApp con prefisso internazionale?' } +]; function getSession(callSid) { - if (!bookingSessions.has(callSid)) { - bookingSessions.set(callSid, { - step: "intent", + if (!sessions.has(callSid)) { + sessions.set(callSid, { + stepIndex: 0, attempts: 0, - data: {}, + data: {} }); } - return bookingSessions.get(callSid); + return sessions.get(callSid); } -function resetAttempts(session) { - session.attempts = 0; +function buildGatherResponse(prompt) { + const response = new TwilioTwiml.VoiceResponse(); + const gather = response.gather({ + input: 'speech', + language: 'it-IT', + speechTimeout: 'auto', + action: '/voice/step', + method: 'POST' + }); + gather.say({ language: 'it-IT' }, prompt); + response.say({ language: 'it-IT' }, 'Non ho ricevuto risposta. Riproviamo.'); + response.redirect({ method: 'POST' }, '/voice'); + return response; } -function incrementAttempts(session) { - session.attempts += 1; +function buildTransferResponse() { + const response = new TwilioTwiml.VoiceResponse(); + if (process.env.FORWARDING_ENABLED === 'true' && process.env.HUMAN_FORWARD_TO) { + response.say({ language: 'it-IT' }, 'Ti trasferisco a un operatore.'); + response.dial({}, process.env.HUMAN_FORWARD_TO); + } else { + response.say({ language: 'it-IT' }, 'Spiacente, non riesco a comprendere la richiesta. Riprova più tardi.'); + } + return response; } -function needsHumanForwarding() { - return ( - String(process.env.FORWARDING_ENABLED || "").toLowerCase() === "true" && - Boolean(process.env.HUMAN_FORWARD_TO) - ); +function normalizeDateInput(value) { + if (!value) return new Date().toISOString().slice(0, 10); + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return new Date().toISOString().slice(0, 10); + } + return parsed.toISOString().slice(0, 10); +} + +function buildStartDateTime(dateInput, timeInput) { + const dateISO = normalizeDateInput(dateInput); + const time = (timeInput || '19:00').replace(/[^0-9:]/g, '') || '19:00'; + const dateTime = new Date(`${dateISO}T${time}`); + if (Number.isNaN(dateTime.getTime())) { + return new Date().toISOString(); + } + return dateTime.toISOString(); } -function createForwardingTwiml() { - if (!needsHumanForwarding()) { - return createSayResponse("Non riesco a capire. Ti richiameremo a breve."); +async function saveBookingIfConfigured(session, callSid) { + const prisma = getPrismaClient(); + if (!prisma) { + return null; } - return createDialResponse(process.env.HUMAN_FORWARD_TO); + + const locale = await ensureDefaultLocale(prisma); + const dateISO = normalizeDateInput(session.data.date); + const businessDay = await upsertBusinessDay(prisma, locale.id, dateISO); + + return prisma.booking.create({ + data: { + localeId: locale.id, + businessDayId: businessDay.id, + name: session.data.name || 'Cliente', + time24: session.data.time || '00:00', + people: Number.parseInt(session.data.people, 10) || 2, + status: 'REQUESTED', + whatsapp: session.data.whatsapp || null, + createdBy: callSid + } + }); } -app.get("/", (req, res) => { - res - .status(200) - .send("TuttiBrilli backend attivo ✅ (usa /healthz, /voice, /whatsapp)"); +app.get('/', (req, res) => { + res.send('AI TuttiBrilli backend attivo'); }); -app.get("/healthz", (req, res) => { - res.status(200).json({ status: "ok" }); +app.get('/healthz', (req, res) => { + res.json({ status: 'ok' }); }); -app.get("/debug/db", async (req, res) => { - if (!dbAvailable()) { - return res.status(200).json({ ok: false, reason: "DATABASE_URL missing" }); - } - const prisma = getPrismaClient(); - try { - const [locali, bookings] = await Promise.all([ - prisma.locale.count(), - prisma.booking.count(), - ]); - return res.status(200).json({ ok: true, locali, bookings }); - } catch (error) { - return res.status(500).json({ ok: false, error: error.message }); - } +app.get('/debug/env', (req, res) => { + const status = REQUIRED_ENV.reduce((acc, key) => { + acc[key] = Boolean(process.env[key]); + return acc; + }, {}); + res.json({ status }); }); -app.post("/debug/seed", async (req, res) => { - if (!dbAvailable()) { - return res.status(200).json({ ok: false, reason: "DATABASE_URL missing" }); +app.get('/debug/db', async (req, res) => { + if (!process.env.DATABASE_URL) { + return res.status(400).json({ ok: false, error: 'DATABASE_URL missing' }); } - const prisma = getPrismaClient(); try { - const locale = await prisma.locale.upsert({ - where: { name: "TuttiBrilli" }, - update: {}, - create: { name: "TuttiBrilli" }, - }); - return res.status(200).json({ ok: true, locale }); + const prisma = getPrismaClient(); + const bookingCount = await prisma.booking.count(); + const businessDayCount = await prisma.businessDay.count(); + return res.json({ ok: true, bookingCount, businessDayCount }); } catch (error) { return res.status(500).json({ ok: false, error: error.message }); } }); -app.post("/debug/calendar-test", async (req, res) => { +app.post('/debug/calendar-test', async (req, res) => { try { - const event = await createTestEvent({ - summary: "Test TuttiBrilli", - description: "Evento test creato da /debug/calendar-test", + const event = await createBookingEvent({ + bookingKey: `debug-${Date.now()}`, + summary: 'Debug Calendar Test', + description: 'Evento di test creato dal debug endpoint', + startDateTimeISO: new Date().toISOString() }); - return res.status(200).json({ ok: true, htmlLink: event.htmlLink }); + res.json({ ok: true, eventId: event.id, htmlLink: event.htmlLink }); } catch (error) { - return res.status(500).json({ ok: false, error: error.message }); + res.status(500).json({ ok: false, error: error.message }); } }); -app.post("/debug/whatsapp-test", async (req, res) => { - const { to, body } = req.body || {}; +app.post('/debug/whatsapp-test', async (req, res) => { + const { to } = req.body; + if (!to) { + return res.status(400).json({ ok: false, error: 'Missing to' }); + } try { - const message = await sendWhatsAppMessage({ - to, - body: body || "Test WhatsApp da TuttiBrilli", - }); - return res.status(200).json({ ok: true, sid: message.sid }); + const message = await sendWhatsAppMessage(to, 'Messaggio WhatsApp di test da TuttiBrilli.'); + res.json({ ok: true, sid: message.sid }); } catch (error) { - return res.status(500).json({ ok: false, error: error.message }); + res.status(500).json({ ok: false, error: error.message }); } }); -app.post("/debug/call-outbound", async (req, res) => { - const { to, from } = req.body || {}; +app.post('/debug/call-outbound', async (req, res) => { + const { to } = req.body; + if (!to) { + return res.status(400).json({ ok: false, error: 'Missing to' }); + } try { - const call = await createOutboundCall({ to, from }); - return res.status(200).json({ ok: true, sid: call.sid }); + const call = await createOutboundCall(to); + res.json({ ok: true, sid: call.sid }); } catch (error) { - return res.status(500).json({ ok: false, error: error.message }); + res.status(500).json({ ok: false, error: error.message }); } }); -app.post("/whatsapp/inbound", (req, res) => { - const from = req.body.From || "sconosciuto"; - const incomingMsg = req.body.Body || ""; - const reply = `Ciao! ✅ Messaggio ricevuto da ${from}. Hai scritto: "${incomingMsg}"`; - res.type("text/xml").status(200).send(createMessageResponse(reply)); +app.post('/whatsapp/inbound', (req, res) => { + console.log('WhatsApp inbound payload:', req.body); + res.status(200).send('OK'); }); -app.post("/voice", (req, res) => { - const callSid = req.body.CallSid; +app.post('/voice', (req, res) => { + const callSid = req.body.CallSid || 'unknown'; const session = getSession(callSid); - session.step = "intent"; - session.attempts = 0; - session.data = { from: req.body.From, callSid }; - - const prompt = - "Benvenuto da TuttiBrilli. Vuoi fare una prenotazione o chiedere informazioni?"; - const twiml = createGatherResponse({ - action: "/voice/step", - prompt, - }); - res.type("text/xml").status(200).send(twiml); + const step = VOICE_STEPS[session.stepIndex]; + const response = buildGatherResponse(step.prompt); + res.type('text/xml').send(response.toString()); }); -app.post("/voice/step", async (req, res) => { - const callSid = req.body.CallSid; - const speechResult = normalizeSpeechText(req.body.SpeechResult || ""); +app.post('/voice/step', async (req, res) => { + const callSid = req.body.CallSid || 'unknown'; + const speechResult = (req.body.SpeechResult || '').trim(); const session = getSession(callSid); - const { data } = session; if (!speechResult) { - incrementAttempts(session); - if (session.attempts >= MAX_ATTEMPTS) { - const twiml = createForwardingTwiml(); - return res.type("text/xml").status(200).send(twiml); + session.attempts += 1; + if (session.attempts >= MAX_RETRIES) { + const transferResponse = buildTransferResponse(); + sessions.delete(callSid); + return res.type('text/xml').send(transferResponse.toString()); } - const twiml = createGatherResponse({ - action: "/voice/step", - prompt: "Non ho capito, puoi ripetere?", - }); - return res.type("text/xml").status(200).send(twiml); + const retryResponse = buildGatherResponse('Non ho capito, puoi ripetere?'); + return res.type('text/xml').send(retryResponse.toString()); } - switch (session.step) { - case "intent": { - if (speechResult.includes("informaz")) { - const twiml = createSayResponse( - "Per informazioni puoi visitare il nostro sito. Arrivederci." - ); - return res.type("text/xml").status(200).send(twiml); - } - session.step = "name"; - resetAttempts(session); - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Perfetto. Qual è il tuo nome?", - }) - ); - } - case "name": { - data.name = speechResult; - session.step = "date"; - resetAttempts(session); - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Per quale data? puoi dire oggi, domani o una data come 12-10.", - }) - ); - } - case "date": { - const dateISO = guessDateFromSpeech(speechResult); - if (!dateISO) { - incrementAttempts(session); - if (session.attempts >= MAX_ATTEMPTS) { - const twiml = createForwardingTwiml(); - return res.type("text/xml").status(200).send(twiml); - } - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Data non valida, ripeti con giorno e mese.", - }) - ); - } - data.dateISO = dateISO; - session.step = "time"; - resetAttempts(session); - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "A che ora?", - }) - ); - } - case "time": { - const time24 = guessTimeFromSpeech(speechResult); - if (!time24) { - incrementAttempts(session); - if (session.attempts >= MAX_ATTEMPTS) { - const twiml = createForwardingTwiml(); - return res.type("text/xml").status(200).send(twiml); - } - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Ora non valida, ad esempio diciannove e trenta.", - }) - ); - } - data.time24 = time24; - session.step = "people"; - resetAttempts(session); - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Per quante persone?", - }) - ); - } - case "people": { - const people = extractNumberFromSpeech(speechResult); - if (!people) { - incrementAttempts(session); - if (session.attempts >= MAX_ATTEMPTS) { - const twiml = createForwardingTwiml(); - return res.type("text/xml").status(200).send(twiml); - } - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Numero non valido, ripeti quante persone.", - }) - ); - } - data.people = people; - session.step = "whatsapp"; - resetAttempts(session); - if ((data.from || "").startsWith("+39")) { - data.whatsapp = data.from; - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: - "Vuoi ricevere la conferma su WhatsApp a questo numero? Rispondi sì o no.", - }) - ); - } - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Qual è il tuo numero WhatsApp?", - }) - ); - } - case "whatsapp": { - if ((data.from || "").startsWith("+39") && !data.confirmedWhatsApp) { - if (speechResult.includes("si") || speechResult.includes("sì")) { - data.confirmedWhatsApp = true; - } else { - data.whatsapp = null; - } - } - if (!data.confirmedWhatsApp && !data.whatsapp) { - const phone = speechResult.replace(/\s/g, ""); - if (!phone.startsWith("+")) { - incrementAttempts(session); - if (session.attempts >= MAX_ATTEMPTS) { - const twiml = createForwardingTwiml(); - return res.type("text/xml").status(200).send(twiml); - } - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Inserisci il numero con prefisso, ad esempio +39...", - }) - ); - } - data.whatsapp = phone; - } - if ((data.from || "").startsWith("+39") && !data.confirmedWhatsApp && !data.whatsapp) { - return res - .type("text/xml") - .status(200) - .send( - createGatherResponse({ - action: "/voice/step", - prompt: "Qual è il tuo numero WhatsApp?", - }) - ); - } + const step = VOICE_STEPS[session.stepIndex]; + session.data[step.key] = speechResult; + session.attempts = 0; + session.stepIndex += 1; - try { - const booking = await createBookingRecord(data); - const event = await createBookingEvent({ - booking, - bookingKey: booking.bookingKey, - }); - await markBookingCalendar(booking.id, event.id); - if (data.whatsapp) { - await sendWhatsAppMessage({ - to: `whatsapp:${data.whatsapp}`, - body: `Prenotazione confermata per ${booking.name} il ${booking.dateISO} alle ${booking.time24} per ${booking.people} persone.`, - }); - } - bookingSessions.delete(callSid); - const twiml = createSayResponse( - "Perfetto, prenotazione registrata. Ti invieremo conferma su WhatsApp." - ); - return res.type("text/xml").status(200).send(twiml); - } catch (error) { - console.error("Booking flow error", error); - const twiml = createSayResponse( - "C'è un problema con il calendario, ti ricontatteremo." - ); - return res.type("text/xml").status(200).send(twiml); - } - } - default: { - const twiml = createSayResponse("Sessione terminata."); - return res.type("text/xml").status(200).send(twiml); - } + if (session.stepIndex < VOICE_STEPS.length) { + const nextStep = VOICE_STEPS[session.stepIndex]; + const nextResponse = buildGatherResponse(nextStep.prompt); + return res.type('text/xml').send(nextResponse.toString()); } -}); -async function createBookingRecord(data) { - if (!dbAvailable()) { - return { - id: "no-db", - bookingKey: data.callSid || `call-${Date.now()}`, - name: data.name, - dateISO: data.dateISO, - time24: data.time24, - people: data.people, - }; + const response = new TwilioTwiml.VoiceResponse(); + response.say({ language: 'it-IT' }, 'Perfetto, sto registrando la tua richiesta.'); + + let bookingRecord = null; + try { + bookingRecord = await saveBookingIfConfigured(session, callSid); + } catch (error) { + console.error('Booking save error:', error); } - const prisma = getPrismaClient(); - const locale = await prisma.locale.upsert({ - where: { name: "TuttiBrilli" }, - update: {}, - create: { name: "TuttiBrilli" }, - }); - const businessDay = await prisma.businessDay.upsert({ - where: { - localeId_dateISO: { - localeId: locale.id, - dateISO: data.dateISO, - }, - }, - update: {}, - create: { - localeId: locale.id, - dateISO: data.dateISO, - }, - }); - const bookingKey = data.callSid || `call-${Date.now()}`; - const booking = await prisma.booking.create({ - data: { - bookingKey, - name: data.name, - dateISO: data.dateISO, - time24: data.time24, - people: data.people, - status: "PENDING", - phone: data.from, - whatsapp: data.whatsapp, - localeId: locale.id, - businessDayId: businessDay.id, - }, - }); - return booking; -} -async function markBookingCalendar(bookingId, calendarEventId) { - if (!dbAvailable() || bookingId === "no-db") { - return; + try { + const event = await createBookingEvent({ + bookingKey: callSid, + summary: `Prenotazione ${session.data.name || 'Cliente'}`, + description: `Prenotazione per ${session.data.people || 'N/A'} persone alle ${session.data.time || 'N/A'}.`, + startDateTimeISO: buildStartDateTime(session.data.date, session.data.time), + metadata: { + bookingId: bookingRecord ? bookingRecord.id : null + } + }); + + if (!session.data.whatsapp) { + throw new Error('Numero WhatsApp mancante'); + } + + await sendWhatsAppMessage( + session.data.whatsapp, + `Ciao ${session.data.name}, prenotazione ricevuta per ${session.data.people} persone alle ${session.data.time}. Evento creato: ${event.htmlLink}` + ); + response.say({ language: 'it-IT' }, 'Prenotazione confermata. Ti ho inviato un messaggio WhatsApp. A presto!'); + } catch (error) { + console.error('Calendar/WhatsApp error:', error); + response.say({ language: 'it-IT' }, 'C’è un problema tecnico sul calendario. Ti ricontatteremo presto.'); } - const prisma = getPrismaClient(); - await prisma.booking.update({ - where: { id: bookingId }, - data: { calendarEventId, status: "CONFIRMED" }, - }); -} + + sessions.delete(callSid); + res.type('text/xml').send(response.toString()); +}); const port = process.env.PORT || 3000; app.listen(port, () => { - console.log(`Server running on port ${port}`); + console.log(`Server listening on port ${port}`); }); From 7941ee4f9bc9fbc4af444632df6d61c07b2ce979 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:28:05 +0100 Subject: [PATCH 085/143] Update package.json --- package.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 71bf57992e..93e9700cc5 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,23 @@ { - "name": "express-hello-world", - "version": "1.0.0", - "description": "Express + Prisma + Twilio + Google Calendar backend for Render", + "name": "tuttibrilli-ai-backoffice", + "version": "0.1.0", + "private": true, "main": "app.js", - "repository": "https://github.com/render-examples/express-hello-world", - "author": "Render Developers", - "license": "MIT", + "type": "commonjs", "scripts": { "start": "node app.js", + "dev": "node app.js", "postinstall": "prisma generate", "migrate": "prisma migrate deploy", - "prisma:generate": "prisma generate" + "migrate:if": "node scripts/migrate-if-configured.js", + "seed": "node prisma/seed.js" }, "dependencies": { "@prisma/client": "^5.18.0", "dotenv": "^16.4.5", - "express": "^5.0.0", - "googleapis": "^140.0.1", + "express": "^4.19.2", + "googleapis": "^133.0.0", "prisma": "^5.18.0", - "twilio": "^5.2.2" + "twilio": "^4.23.0" } } From aa9c564f2b4dea5d732199fcac6bd57455a8de0a Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:28:28 +0100 Subject: [PATCH 086/143] Update schema.prisma --- prisma/schema.prisma | 122 ++++++++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 54 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f896dfb2a4..744f816d0e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,91 +9,105 @@ datasource db { enum UserRole { OWNER - STAFF - CUSTOMER + ADMIN + SALA + CUCINA } enum BookingStatus { - PENDING + REQUESTED CONFIRMED CANCELLED } +enum SaleCategory { + VINO + FOOD + BIBITE +} + model Locale { - id String @id @default(cuid()) - name String @unique - createdAt DateTime @default(now()) + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) users User[] days BusinessDay[] bookings Booking[] - presences Presence[] - sales Sale[] - ratings ServiceRating[] } model User { - id String @id @default(cuid()) - name String - role UserRole @default(STAFF) - localeId String - locale Locale @relation(fields: [localeId], references: [id]) - createdAt DateTime @default(now()) - bookings Booking[] @relation("BookingCreatedBy") + id String @id @default(cuid()) + email String @unique + passwordHash String + role UserRole + localeId String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + locale Locale @relation(fields: [localeId], references: [id]) } model BusinessDay { - id String @id @default(cuid()) - dateISO String + id String @id @default(cuid()) localeId String - locale Locale @relation(fields: [localeId], references: [id]) + dateISO String + isClosed Boolean @default(false) + notes String? + createdAt DateTime @default(now()) + locale Locale @relation(fields: [localeId], references: [id]) bookings Booking[] + presences Presence[] + sales Sale[] + rating ServiceRating? @@unique([localeId, dateISO]) } model Booking { - id String @id @default(cuid()) - bookingKey String @unique - name String - dateISO String - time24 String - people Int - status BookingStatus @default(PENDING) - phone String? - whatsapp String? - calendarEventId String? - localeId String - businessDayId String - createdById String? - createdAt DateTime @default(now()) - - locale Locale @relation(fields: [localeId], references: [id]) - businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) - createdBy User? @relation("BookingCreatedBy", fields: [createdById], references: [id]) + id String @id @default(cuid()) + localeId String + businessDayId String + name String + time24 String + people Int + status BookingStatus + phone String? + whatsapp String? + notes String? + calendarEventId String? + createdBy String + createdAt DateTime @default(now()) + locale Locale @relation(fields: [localeId], references: [id]) + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) } model Presence { - id String @id @default(cuid()) - localeId String - userId String - dateISO String - locale Locale @relation(fields: [localeId], references: [id]) - user User @relation(fields: [userId], references: [id]) + id String @id @default(cuid()) + businessDayId String + timeSlot String + expectedPeople Int + actualPeople Int? + createdAt DateTime @default(now()) + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) } model Sale { - id String @id @default(cuid()) - localeId String - amount Int - dateISO String - locale Locale @relation(fields: [localeId], references: [id]) + id String @id @default(cuid()) + businessDayId String + category SaleCategory + productName String + quantity Int + totalAmount Float + createdAt DateTime @default(now()) + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) } model ServiceRating { - id String @id @default(cuid()) - localeId String - rating Int - comment String? - dateISO String - locale Locale @relation(fields: [localeId], references: [id]) + id String @id @default(cuid()) + businessDayId String @unique + customerSatisfaction Int + serviceEfficiency Int + kitchenEfficiency Int + notes String? + createdAt DateTime @default(now()) + businessDay BusinessDay @relation(fields: [businessDayId], references: [id]) } From 474ef8217078d1f4936123b1221035d32c6454a5 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:34:10 +0100 Subject: [PATCH 087/143] Create seed.js --- prisma/seed.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 prisma/seed.js diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 0000000000..746910b5f5 --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,30 @@ +const { PrismaClient } = require('@prisma/client'); + +async function main() { + if (!process.env.DATABASE_URL) { + console.log('Skip seed (DATABASE_URL missing)'); + return; + } + const prisma = new PrismaClient(); + const locale = await prisma.locale.findFirst(); + if (!locale) { + await prisma.locale.create({ data: { name: 'Locale Principale' } }); + console.log('Created default locale'); + } else { + console.log('Default locale already exists'); + } +} + +main() + .catch((error) => { + console.error('Seed error:', error); + process.exitCode = 1; + }) + .finally(async () => { + try { + const prisma = new PrismaClient(); + await prisma.$disconnect(); + } catch (error) { + console.error('Prisma disconnect error:', error); + } + }); From d2b293669f9e816784ad083a7a7d49d58d66aaef Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:35:42 +0100 Subject: [PATCH 088/143] Create migrate-if-configured.js --- scripts/migrate-if-configured.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 scripts/migrate-if-configured.js diff --git a/scripts/migrate-if-configured.js b/scripts/migrate-if-configured.js new file mode 100644 index 0000000000..5b63a81015 --- /dev/null +++ b/scripts/migrate-if-configured.js @@ -0,0 +1,13 @@ +const { execSync } = require('child_process'); + +if (!process.env.DATABASE_URL) { + console.log('Skip migrations (DATABASE_URL missing)'); + process.exit(0); +} + +try { + execSync('npx prisma migrate deploy', { stdio: 'inherit' }); +} catch (error) { + console.error('Migration failed:', error.message); + process.exit(1); +} From 52f62c12f1dd77e41e1af71553a5cd808c0b6209 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:36:20 +0100 Subject: [PATCH 089/143] Create db.js --- lib/db.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 lib/db.js diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000000..145b8d528d --- /dev/null +++ b/lib/db.js @@ -0,0 +1,43 @@ +const { PrismaClient } = require('@prisma/client'); + +let prismaClient; + +function getPrismaClient() { + if (!process.env.DATABASE_URL) { + return null; + } + if (!prismaClient) { + prismaClient = new PrismaClient(); + } + return prismaClient; +} + +async function ensureDefaultLocale(prisma) { + const existing = await prisma.locale.findFirst(); + if (existing) return existing; + return prisma.locale.create({ + data: { + name: 'Locale Principale' + } + }); +} + +async function upsertBusinessDay(prisma, localeId, dateISO) { + return prisma.businessDay.upsert({ + where: { + localeId_dateISO: { localeId, dateISO } + }, + update: {}, + create: { + localeId, + dateISO, + isClosed: false + } + }); +} + +module.exports = { + getPrismaClient, + ensureDefaultLocale, + upsertBusinessDay +}; From 2e88cb668266539ecb2fbb1f18da9314161be598 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:36:43 +0100 Subject: [PATCH 090/143] Update calendar.js --- lib/calendar.js | 115 +++++++++++++++++++++++------------------------- 1 file changed, 56 insertions(+), 59 deletions(-) diff --git a/lib/calendar.js b/lib/calendar.js index cc33fafa1a..dce5e91e08 100644 --- a/lib/calendar.js +++ b/lib/calendar.js @@ -1,90 +1,87 @@ -const { google } = require("googleapis"); +const { google } = require('googleapis'); -function getServiceAccount() { - const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64; - if (!raw) { - throw new Error("GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing"); +function getCredentials() { + if (!process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64) { + throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing'); } - const json = Buffer.from(raw, "base64").toString("utf-8"); - return JSON.parse(json); + const decoded = Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64, 'base64').toString('utf8'); + return JSON.parse(decoded); } function getCalendarClient() { - const credentials = getServiceAccount(); - const auth = new google.auth.JWT({ - email: credentials.client_email, - key: credentials.private_key, - scopes: ["https://www.googleapis.com/auth/calendar"], + if (!process.env.GOOGLE_CALENDAR_ID) { + throw new Error('GOOGLE_CALENDAR_ID missing'); + } + const credentials = getCredentials(); + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/calendar'] }); - return google.calendar({ version: "v3", auth }); + return google.calendar({ version: 'v3', auth }); } -async function findExistingEvent(calendar, calendarId, bookingKey) { +async function findExistingEvent(calendar, bookingKey) { + const calendarId = process.env.GOOGLE_CALENDAR_ID; const response = await calendar.events.list({ calendarId, privateExtendedProperty: `bookingKey=${bookingKey}`, - maxResults: 1, - singleEvents: true, + maxResults: 1 + }); + const [event] = response.data.items || []; + if (event) return event; + + const fallback = await calendar.events.list({ + calendarId, + q: bookingKey, + maxResults: 1 }); - const items = response.data.items || []; - return items.length ? items[0] : null; + const [fallbackEvent] = fallback.data.items || []; + return fallbackEvent || null; } -async function createBookingEvent({ booking, bookingKey }) { - const calendarId = process.env.GOOGLE_CALENDAR_ID; - if (!calendarId) { - throw new Error("GOOGLE_CALENDAR_ID missing"); - } +async function createBookingEvent({ bookingKey, summary, description, startDateTimeISO, metadata }) { const calendar = getCalendarClient(); - const existing = await findExistingEvent(calendar, calendarId, bookingKey); + const calendarId = process.env.GOOGLE_CALENDAR_ID; + const existing = await findExistingEvent(calendar, bookingKey); if (existing) { return existing; } - const duration = Number(process.env.DEFAULT_EVENT_DURATION_MINUTES || 90); - const start = new Date(`${booking.dateISO}T${booking.time24}:00+02:00`); - const end = new Date(start.getTime() + duration * 60000); + const durationMinutes = Number.parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || '120', 10); + const start = new Date(startDateTimeISO); + const end = new Date(start.getTime() + durationMinutes * 60 * 1000); const event = { - summary: `Prenotazione ${booking.name}`, - description: `Prenotazione ${booking.name} (${booking.people} persone). BookingKey: ${bookingKey}`, - start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, - end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, + summary: summary || 'Prenotazione', + description: `${description || ''}\nBookingKey: ${bookingKey}`, + start: { + dateTime: start.toISOString(), + timeZone: 'Europe/Rome' + }, + end: { + dateTime: end.toISOString(), + timeZone: 'Europe/Rome' + }, extendedProperties: { private: { bookingKey, - }, - }, + ...(metadata || {}) + } + } }; - const response = await calendar.events.insert({ - calendarId, - requestBody: event, - }); - return response.data; -} - -async function createTestEvent({ summary, description }) { - const calendarId = process.env.GOOGLE_CALENDAR_ID; - if (!calendarId) { - throw new Error("GOOGLE_CALENDAR_ID missing"); + try { + const response = await calendar.events.insert({ + calendarId, + requestBody: event + }); + return response.data; + } catch (error) { + console.error('Calendar insert error:', error.response?.data || error.message); + throw error; } - const calendar = getCalendarClient(); - const start = new Date(); - const end = new Date(start.getTime() + 30 * 60000); - const response = await calendar.events.insert({ - calendarId, - requestBody: { - summary, - description, - start: { dateTime: start.toISOString(), timeZone: "Europe/Rome" }, - end: { dateTime: end.toISOString(), timeZone: "Europe/Rome" }, - }, - }); - return response.data; } module.exports = { - createBookingEvent, - createTestEvent, + createBookingEvent }; From af47d04725ba5d315cce17908e8e7d5a0cf37787 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:37:05 +0100 Subject: [PATCH 091/143] Update twilio.js --- lib/twilio.js | 157 +++++++++++++------------------------------------- 1 file changed, 41 insertions(+), 116 deletions(-) diff --git a/lib/twilio.js b/lib/twilio.js index 246af3f1ec..0573ac5260 100644 --- a/lib/twilio.js +++ b/lib/twilio.js @@ -1,139 +1,64 @@ -const twilio = require("twilio"); +const twilio = require('twilio'); -function xmlEscape(input) { - return String(input) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\"/g, """) - .replace(/'/g, "'"); -} - -function twiml(inner) { - return `\n\n${inner}\n`; -} - -function createSayResponse(text) { - return twiml(`${xmlEscape(text)}`); -} - -function createMessageResponse(text) { - return twiml(`${xmlEscape(text)}`); -} - -function createGatherResponse({ action, prompt }) { - return twiml( - `\n` + - `${xmlEscape(prompt)}\n` + - `` - ); -} - -function createDialResponse(number) { - return twiml( - `Ti metto in contatto con un operatore.\n${xmlEscape( - number - )}` - ); -} - -function normalizeSpeechText(text) { - return String(text || "").trim().toLowerCase(); +function getTwilioClient() { + if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) { + throw new Error('Twilio credentials missing'); + } + return twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); } -function guessDateFromSpeech(text) { - const now = new Date(); - if (text.includes("oggi")) { - return now.toISOString().slice(0, 10); - } - if (text.includes("domani")) { - const tomorrow = new Date(now); - tomorrow.setDate(now.getDate() + 1); - return tomorrow.toISOString().slice(0, 10); +function getVoiceFromNumber() { + if (process.env.HUMAN_FORWARD_TO) { + return process.env.HUMAN_FORWARD_TO; } - const match = text.match(/(\d{1,2})[\/-](\d{1,2})/); - if (match) { - const day = Number(match[1]); - const month = Number(match[2]); - const year = now.getFullYear(); - const date = new Date(Date.UTC(year, month - 1, day)); - if (!Number.isNaN(date.getTime())) { - return date.toISOString().slice(0, 10); - } + if (process.env.TWILIO_WHATSAPP_FROM) { + return process.env.TWILIO_WHATSAPP_FROM.replace('whatsapp:', ''); } return null; } -function guessTimeFromSpeech(text) { - const match = text.match(/(\d{1,2})(?:[:\s](\d{1,2}))?/); - if (!match) { - return null; +async function sendWhatsAppMessage(to, body) { + if (!process.env.TWILIO_WHATSAPP_FROM) { + throw new Error('TWILIO_WHATSAPP_FROM missing'); } - let hour = Number(match[1]); - let minute = match[2] ? Number(match[2]) : 0; - if (text.includes("mezza")) { - minute = 30; - } - if (hour > 23 || minute > 59) { - return null; - } - return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; -} - -function extractNumberFromSpeech(text) { - const match = text.match(/\d+/); - if (!match) { - return null; + if (!to) { + throw new Error('Missing destination number'); } - const value = Number(match[0]); - return Number.isFinite(value) ? value : null; -} - -function getTwilioClient() { - if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) { - throw new Error("Twilio credentials missing"); + const client = getTwilioClient(); + try { + return await client.messages.create({ + from: process.env.TWILIO_WHATSAPP_FROM, + to: to.startsWith('whatsapp:') ? to : `whatsapp:${to}`, + body + }); + } catch (error) { + console.error('Twilio WhatsApp error:', error.code, error.message); + throw error; } - return twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); } -async function sendWhatsAppMessage({ to, body }) { - if (!to) { - throw new Error("WhatsApp destination missing"); +async function createOutboundCall(to) { + if (!process.env.BASE_URL) { + throw new Error('BASE_URL missing'); } - const from = process.env.TWILIO_WHATSAPP_FROM; + const from = getVoiceFromNumber(); if (!from) { - throw new Error("TWILIO_WHATSAPP_FROM missing"); + throw new Error('Missing outbound caller ID (set HUMAN_FORWARD_TO or TWILIO_WHATSAPP_FROM)'); } const client = getTwilioClient(); - return client.messages.create({ to, from, body }); -} - -async function createOutboundCall({ to, from }) { - const client = getTwilioClient(); - const fromNumber = from || process.env.TWILIO_VOICE_FROM; - if (!fromNumber) { - throw new Error("TWILIO_VOICE_FROM missing"); - } - const baseUrl = process.env.BASE_URL || ""; - if (!baseUrl) { - throw new Error("BASE_URL missing"); + try { + return await client.calls.create({ + to, + from, + url: `${process.env.BASE_URL}/voice` + }); + } catch (error) { + console.error('Twilio call error:', error.code, error.message); + throw error; } - return client.calls.create({ - to, - from: fromNumber, - url: `${baseUrl}/voice`, - }); } module.exports = { - createSayResponse, - createMessageResponse, - createGatherResponse, - createDialResponse, - normalizeSpeechText, - guessDateFromSpeech, - guessTimeFromSpeech, - extractNumberFromSpeech, sendWhatsAppMessage, - createOutboundCall, + createOutboundCall }; From f87647aadfef4a6e8204e9a5fde725a82051f430 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:37:53 +0100 Subject: [PATCH 092/143] Update .env.example --- .env.example | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 6cc3a13068..2ac1ef61c9 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,14 @@ - (cd "$(git rev-parse --show-toplevel)" && git apply --3way <<'EOF' -diff --git a/.env.example b/.env.example -new file mode 100644 -index 0000000000000000000000000000000000000000..d4623c81eca7e19b4b6a605c3eb54e20cde32064 ---- /dev/null -+++ b/.env.example -@@ -0,0 +1,12 @@ -+PORT=3000 -+BASE_URL=https://your-service.onrender.com -+DATABASE_URL=postgresql://user:password@host:5432/dbname -+GOOGLE_SERVICE_ACCOUNT_JSON_B64=base64-encoded-json -+GOOGLE_CALENDAR_ID=your-calendar-id@group.calendar.google.com -+DEFAULT_EVENT_DURATION_MINUTES=90 -+TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -+TWILIO_AUTH_TOKEN=your_auth_token -+TWILIO_WHATSAPP_FROM=whatsapp:+14155238886 -+TWILIO_VOICE_FROM=+1234567890 -+HUMAN_FORWARD_TO=+391234567890 -+FORWARDING_ENABLED=true - -EOF -) +PORT=3000 +BASE_URL=https:// +DATABASE_URL= +DEFAULT_EVENT_DURATION_MINUTES=120 + +GOOGLE_SERVICE_ACCOUNT_JSON_B64= +GOOGLE_CALENDAR_ID= + +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_WHATSAPP_FROM=whatsapp:+1415.... + +FORWARDING_ENABLED=false +HUMAN_FORWARD_TO=+39.... From e5623009e94a48832b1d068bfa0d9c55e689957d Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Sun, 4 Jan 2026 23:38:23 +0100 Subject: [PATCH 093/143] Update README.md --- README.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cac07f3f83..694a23c32c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ -# README +# Tuttibrilli AI Backoffice -This is the [Express](https://expressjs.com) [Hello world](https://expressjs.com/en/starter/hello-world.html) example on [Render](https://render.com). +Backend Node.js (Express) pronto per Render con Prisma + PostgreSQL, Twilio Voice/WhatsApp e Google Calendar. -The app in this repo is deployed at [https://express.onrender.com](https://express.onrender.com). +## Requisiti +- Node.js 18+ +- PostgreSQL (Render Postgres) -## Deployment - -See https://render.com/docs/deploy-node-express-app or follow the steps below: - -Create a new web service with the following values: - * Build Command: `yarn` - * Start Command: `node app.js` - -That's it! Your web service will be live on your Render URL as soon as the build finishes. +## Setup locale +```bash +npm install +cp .env.example .env +npm start From 98ffe58cc87c054158e15f2ed99444ccda961333 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:00:30 +0100 Subject: [PATCH 094/143] Update app.js From a0e4720e53b1f2ce7a79be8991182228d4185c8d Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:00:54 +0100 Subject: [PATCH 095/143] Update package.json From 9b1d5f78930a98124f2c73b6875f46a569f8c4b8 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:01:19 +0100 Subject: [PATCH 096/143] Update schema.prisma From 57d948e38c88d5c5efdc82ccdae9a6f87721bf7a Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:01:42 +0100 Subject: [PATCH 097/143] Update seed.js From 22cc3472415df243bf5418c53aab58335c4d0c0d Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:02:10 +0100 Subject: [PATCH 098/143] Update migrate-if-configured.js From eb58da85ea28cc2f04aca4f3a8b21412ef7f4cfc Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:02:42 +0100 Subject: [PATCH 099/143] Update db.js From 918b5f66280f9d5ae3493a4476c355173f56cc23 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:03:02 +0100 Subject: [PATCH 100/143] Update calendar.js From 7654079f0ebe651648cd2d08efcc972728ee25f4 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:03:26 +0100 Subject: [PATCH 101/143] Update twilio.js From a34b03ad162624d21491422524d062ccf2d566ba Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:04:00 +0100 Subject: [PATCH 102/143] Update .env.example From cb08cd22139bdf4106ccc3a3d7e019be8173df59 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:04:31 +0100 Subject: [PATCH 103/143] Update README.md From efbea7d66aa55a6d291878aa474ffce912fddfe0 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 00:24:35 +0100 Subject: [PATCH 104/143] Update yarn.lock --- yarn.lock | 579 ++++++++++++++++-------------------------------------- 1 file changed, 166 insertions(+), 413 deletions(-) diff --git a/yarn.lock b/yarn.lock index f25e2db0fe..e11b5d4a12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,22 +2,46 @@ # yarn lockfile v1 -"@isaacs/cliui@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" - integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - dependencies: - string-width "^5.1.2" - string-width-cjs "npm:string-width@^4.2.0" - strip-ansi "^7.0.1" - strip-ansi-cjs "npm:strip-ansi@^6.0.1" - wrap-ansi "^8.1.0" - wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" - -"@pkgjs/parseargs@^0.11.0": - version "0.11.0" - resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" - integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@prisma/client@^5.18.0": + version "5.22.0" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.22.0.tgz#da1ca9c133fbefe89e0da781c75e1c59da5f8802" + integrity sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA== + +"@prisma/debug@5.22.0": + version "5.22.0" + resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-5.22.0.tgz#58af56ed7f6f313df9fb1042b6224d3174bbf412" + integrity sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ== + +"@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2": + version "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz#d534dd7235c1ba5a23bacd5b92cc0ca3894c28f4" + integrity sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ== + +"@prisma/engines@5.22.0": + version "5.22.0" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.22.0.tgz#28f3f52a2812c990a8b66eb93a0987816a5b6d84" + integrity sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA== + dependencies: + "@prisma/debug" "5.22.0" + "@prisma/engines-version" "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2" + "@prisma/fetch-engine" "5.22.0" + "@prisma/get-platform" "5.22.0" + +"@prisma/fetch-engine@5.22.0": + version "5.22.0" + resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz#4fb691b483a450c5548aac2f837b267dd50ef52e" + integrity sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA== + dependencies: + "@prisma/debug" "5.22.0" + "@prisma/engines-version" "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2" + "@prisma/get-platform" "5.22.0" + +"@prisma/get-platform@5.22.0": + version "5.22.0" + resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-5.22.0.tgz#fc675bc9d12614ca2dade0506c9c4a77e7dddacd" + integrity sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q== + dependencies: + "@prisma/debug" "5.22.0" accepts@~1.3.8: version "1.3.8" @@ -39,28 +63,6 @@ agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-regex@^6.0.1: - version "6.2.2" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" - integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== - -ansi-styles@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^6.1.0: - version "6.2.3" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" - integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== - array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -71,7 +73,7 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -axios@^1.12.0: +axios@^1.6.0: version "1.13.2" resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== @@ -80,11 +82,6 @@ axios@^1.12.0: form-data "^4.0.4" proxy-from-env "^1.1.0" -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - base64-js@^1.3.0: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -113,13 +110,6 @@ body-parser@~1.20.3: type-is "~1.6.18" unpipe "~1.0.0" -brace-expansion@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== - dependencies: - balanced-match "^1.0.0" - buffer-equal-constant-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" @@ -146,18 +136,6 @@ call-bound@^1.0.2: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -172,12 +150,7 @@ content-disposition@~0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - -content-type@~1.0.5: +content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -192,20 +165,6 @@ cookie@~0.7.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -cross-spawn@^7.0.6: - version "7.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" - integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -data-uri-to-buffer@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" - integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== - dayjs@^1.11.9: version "1.11.19" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" @@ -240,6 +199,11 @@ destroy@1.2.0, destroy@~1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +dotenv@^16.4.5: + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -249,11 +213,6 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -eastasianwidth@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" - integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== - ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" @@ -264,17 +223,7 @@ ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -emoji-regex@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" - integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== encodeurl@~2.0.0: version "2.0.0" @@ -311,12 +260,12 @@ es-set-tostringtag@^2.1.0: escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^4.19.2: version "4.22.1" @@ -360,14 +309,6 @@ extend@^3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -fetch-blob@^3.1.2, fetch-blob@^3.1.4: - version "3.2.0" - resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" - integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== - dependencies: - node-domexception "^1.0.0" - web-streams-polyfill "^3.0.3" - finalhandler@~1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" @@ -386,14 +327,6 @@ follow-redirects@^1.15.6: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== -foreground-child@^3.1.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" - integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== - dependencies: - cross-spawn "^7.0.6" - signal-exit "^4.0.1" - form-data@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" @@ -405,13 +338,6 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" -formdata-polyfill@^4.0.10: - version "4.0.10" - resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" - integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== - dependencies: - fetch-blob "^3.1.2" - forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -422,28 +348,34 @@ fresh@~0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fsevents@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -gaxios@^7.0.0, gaxios@^7.0.0-rc.4: - version "7.1.3" - resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-7.1.3.tgz#c5312f4254abc1b8ab53aef30c22c5229b80b1e1" - integrity sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ== +gaxios@^6.0.0, gaxios@^6.0.3, gaxios@^6.1.1: + version "6.7.1" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.7.1.tgz#ebd9f7093ede3ba502685e73390248bb5b7f71fb" + integrity sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ== dependencies: extend "^3.0.2" https-proxy-agent "^7.0.1" - node-fetch "^3.3.2" - rimraf "^5.0.1" + is-stream "^2.0.0" + node-fetch "^2.6.9" + uuid "^9.0.1" -gcp-metadata@^8.0.0: - version "8.1.2" - resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz#e62e3373ddf41fc727ccc31c55c687b798bee898" - integrity sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== +gcp-metadata@^6.1.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-6.1.1.tgz#f65aa69f546bc56e116061d137d3f5f90bdec494" + integrity sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A== dependencies: - gaxios "^7.0.0" - google-logging-utils "^1.0.0" + gaxios "^6.1.1" + google-logging-utils "^0.0.2" json-bigint "^1.0.0" get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: @@ -470,106 +402,46 @@ get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" -glob@^10.3.7: - version "10.5.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" - integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== - dependencies: - foreground-child "^3.1.0" - jackspeak "^3.1.2" - minimatch "^9.0.4" - minipass "^7.1.2" - package-json-from-dist "^1.0.0" - path-scurry "^1.11.1" - -google-auth-library@^10.1.0, google-auth-library@^10.2.0: - version "10.5.0" - resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-10.5.0.tgz#3f0ebd47173496b91d2868f572bb8a8180c4b561" - integrity sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w== +google-auth-library@^9.0.0, google-auth-library@^9.7.0: + version "9.15.1" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-9.15.1.tgz#0c5d84ed1890b2375f1cd74f03ac7b806b392928" + integrity sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng== dependencies: base64-js "^1.3.0" ecdsa-sig-formatter "^1.0.11" - gaxios "^7.0.0" - gcp-metadata "^8.0.0" - google-logging-utils "^1.0.0" - gtoken "^8.0.0" + gaxios "^6.1.1" + gcp-metadata "^6.1.0" + gtoken "^7.0.0" jws "^4.0.0" -google-logging-utils@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz#17b71f1f95d266d2ddd356b8f00178433f041b17" - integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== +google-logging-utils@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/google-logging-utils/-/google-logging-utils-0.0.2.tgz#5fd837e06fa334da450433b9e3e1870c1594466a" + integrity sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ== -googleapis-common@^8.0.0: - version "8.0.1" - resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-8.0.1.tgz#5dc9042ec095d75c841e0e95cbeb17a88c010015" - integrity sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A== +googleapis-common@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-7.2.0.tgz#5c19102c9af1e5d27560be5e69ee2ccf68755d42" + integrity sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA== dependencies: extend "^3.0.2" - gaxios "^7.0.0-rc.4" - google-auth-library "^10.1.0" + gaxios "^6.0.3" + google-auth-library "^9.7.0" qs "^6.7.0" url-template "^2.0.8" + uuid "^9.0.0" -googleapis@^169.0.0: - version "169.0.0" - resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-169.0.0.tgz#7b7fffcdf63ea29943625b6bd9ced17667053d80" - integrity sha512-IOGMG8tljCZSLvYgdojRu6mB10KEsK0J7X62sXXlQz9koe5BUAW+rqkY3qhQM9wXM6hVL3/Hase7XbxoMyeYiQ== +googleapis@^133.0.0: + version "133.0.0" + resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-133.0.0.tgz#e8d2f7128eb60d1c268dde91b6e17f5c5403790a" + integrity sha512-6xyc49j+x7N4smawJs/q1i7mbSkt6SYUWWd9RbsmmDW7gRv+mhwZ4xT+XkPihZcNyo/diF//543WZq4szdS74w== dependencies: - google-auth-library "^10.2.0" - googleapis-common "^8.0.0" + google-auth-library "^9.0.0" + googleapis-common "^7.0.0" gopd@^1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" - integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== - -gtoken@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-8.0.0.tgz#d67a0e346dd441bfb54ad14040ddc3b632886575" - integrity sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw== - dependencies: - gaxios "^7.0.0" - jws "^4.0.0" - -has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-symbols@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" - integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== - -has-tostringtag@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" - integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - dependencies: - has-symbols "^1.0.3" - -hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -http-errors@~2.0.0, http-errors@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" - integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== - dependencies: - depd "~2.0.0" - inherits "~2.0.4" - setprototypeof "~1.2.0" - statuses "~2.0.2" - toidentifier "~1.0.1" - -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + resolved "https://registry.yarnpkg.com/…499 tokens truncated…a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== dependencies: agent-base "6" @@ -600,24 +472,10 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -jackspeak@^3.1.2: - version "3.4.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" - integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== - dependencies: - "@isaacs/cliui" "^8.0.2" - optionalDependencies: - "@pkgjs/parseargs" "^0.11.0" +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== json-bigint@^1.0.0: version "1.0.0" @@ -626,7 +484,7 @@ json-bigint@^1.0.0: dependencies: bignumber.js "^9.0.0" -jsonwebtoken@^9.0.2: +jsonwebtoken@^9.0.0: version "9.0.3" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== @@ -694,11 +552,6 @@ lodash.once@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== -lru-cache@^10.2.0: - version "10.4.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" - integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -707,7 +560,7 @@ math-intrinsics@^1.1.0: media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== merge-descriptors@1.0.3: version "1.0.3" @@ -717,53 +570,29 @@ merge-descriptors@1.0.3: methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= - -mime-db@1.40.0: - version "1.40.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" - integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" -mime-types@~2.1.24: - version "2.1.24" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" - integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== - dependencies: - mime-db "1.40.0" - mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -minimatch@^9.0.4: - version "9.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== - dependencies: - brace-expansion "^2.0.1" - -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== - ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" @@ -775,19 +604,12 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -node-domexception@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - -node-fetch@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" - integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== +node-fetch@^2.6.9: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== dependencies: - data-uri-to-buffer "^4.0.0" - fetch-blob "^3.1.4" - formdata-polyfill "^4.0.10" + whatwg-url "^5.0.0" object-inspect@^1.13.3: version "1.13.4" @@ -801,34 +623,25 @@ on-finished@~2.4.1: dependencies: ee-first "1.1.1" -package-json-from-dist@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" - integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== - parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-scurry@^1.11.1: - version "1.11.1" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" - integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== - dependencies: - lru-cache "^10.2.0" - minipass "^5.0.0 || ^6.0.2 || ^7.0.0" - path-to-regexp@~0.1.12: version "0.1.12" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== +prisma@^5.18.0: + version "5.22.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.22.0.tgz#1f6717ff487cdef5f5799cc1010459920e2e6197" + integrity sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A== + dependencies: + "@prisma/engines" "5.22.0" + optionalDependencies: + fsevents "2.3.3" + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -843,12 +656,17 @@ proxy-from-env@^1.1.0: integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== qs@^6.7.0, qs@^6.9.4, qs@~6.14.0: - version "6.14.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" - integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== + version "6.14.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159" + integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== dependencies: side-channel "^1.1.0" +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -864,12 +682,10 @@ raw-body@~2.5.3: iconv-lite "~0.4.24" unpipe "~1.0.0" -rimraf@^5.0.1: - version "5.0.10" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" - integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== - dependencies: - glob "^10.3.7" +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== safe-buffer@5.2.1, safe-buffer@^5.0.1: version "5.2.1" @@ -925,18 +741,6 @@ setprototypeof@1.2.0, setprototypeof@~1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - side-channel-list@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" @@ -977,80 +781,33 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" - integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== - statuses@~2.0.1, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^5.0.1, string-width@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" - integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== - dependencies: - eastasianwidth "^0.2.0" - emoji-regex "^9.2.2" - strip-ansi "^7.0.1" - -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^7.0.1: - version "7.1.2" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" - integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== - dependencies: - ansi-regex "^6.0.1" - toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -twilio@^5.11.1: - version "5.11.1" - resolved "https://registry.yarnpkg.com/twilio/-/twilio-5.11.1.tgz#9b2bed75e01f198b9fba2459fe7fff26e40c915b" - integrity sha512-LQuLrAwWk7dsu7S5JQWzLRe17qdD4/7OJcwZG6kYWMJILtxI7pXDHksu9DcIF/vKpSpL1F0/sA9uSF3xuVizMQ== +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +twilio@^4.23.0: + version "4.23.0" + resolved "https://registry.yarnpkg.com/twilio/-/twilio-4.23.0.tgz#46b69c231fc7aa9488a7bde09b8659da8c459a56" + integrity sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q== dependencies: - axios "^1.12.0" + axios "^1.6.0" dayjs "^1.11.9" https-proxy-agent "^5.0.0" - jsonwebtoken "^9.0.2" + jsonwebtoken "^9.0.0" qs "^6.9.4" scmp "^2.1.0" + url-parse "^1.5.9" xmlbuilder "^13.0.2" type-is@~1.6.18: @@ -1064,7 +821,15 @@ type-is@~1.6.18: unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +url-parse@^1.5.9: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" url-template@^2.0.8: version "2.0.8" @@ -1074,42 +839,30 @@ url-template@^2.0.8: utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@^9.0.0, uuid@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= - -web-streams-polyfill@^3.0.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" - integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" - integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== dependencies: - ansi-styles "^6.1.0" - string-width "^5.0.1" - strip-ansi "^7.0.1" + tr46 "~0.0.3" + webidl-conversions "^3.0.0" xmlbuilder@^13.0.2: version "13.0.2" From 67f1361e5a80b2e1d496d528faaf4c2be6fd8087 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 10:37:32 +0100 Subject: [PATCH 105/143] Update yarn.lock --- yarn.lock | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index e11b5d4a12..e30dacb824 100644 --- a/yarn.lock +++ b/yarn.lock @@ -441,7 +441,50 @@ googleapis@^133.0.0: gopd@^1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/…499 tokens truncated…a25ea30d0005d6" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +gtoken@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-7.1.0.tgz#d61b4ebd10132222817f7222b1e6064bd463fc26" + integrity sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw== + dependencies: + gaxios "^6.0.0" + jws "^4.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +http-errors@~2.0.0, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== dependencies: agent-base "6" From 3995b35b5c0acb94530a4b81ad0bd95ab3dcd9e7 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:29:59 +0100 Subject: [PATCH 106/143] Update app.js From 5d5ae517c38de8f7f3874207e4f6afb4da5728f8 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:30:26 +0100 Subject: [PATCH 107/143] Update package.json From 0559991e050d0a8edbf36e28ad20cb8fc5afae8b Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:30:59 +0100 Subject: [PATCH 108/143] Update schema.prisma From bc9ed34d7128dddd5a5df0a93d03a0bd83f37624 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:31:22 +0100 Subject: [PATCH 109/143] Update seed.js From 63719b2cd5a6e668a0044bbf1869b06651d79187 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:31:42 +0100 Subject: [PATCH 110/143] Update migrate-if-configured.js From dcd215a0665e6a387c2a3a553ffabefe97878e25 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:32:02 +0100 Subject: [PATCH 111/143] Update db.js From 40d20165892e30434f40ce0206b3193231617a6e Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:32:21 +0100 Subject: [PATCH 112/143] Update calendar.js --- lib/calendar.js | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/calendar.js b/lib/calendar.js index dce5e91e08..b05189445b 100644 --- a/lib/calendar.js +++ b/lib/calendar.js @@ -4,8 +4,35 @@ function getCredentials() { if (!process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64) { throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing'); } - const decoded = Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64, 'base64').toString('utf8'); - return JSON.parse(decoded); + const rawEnv = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64.trim(); + const decoded = Buffer.from(rawEnv, 'base64').toString('utf8').trim(); + + try { + return JSON.parse(decoded); + } catch (error) { + if (rawEnv.startsWith('{')) { + try { + return JSON.parse(rawEnv); + } catch (rawError) { + throw rawError; + } + } + + const firstBrace = decoded.indexOf('{'); + const lastBrace = decoded.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + const sliced = decoded.slice(firstBrace, lastBrace + 1); + try { + return JSON.parse(sliced); + } catch (sliceError) { + throw sliceError; + } + } + + throw new Error( + 'Invalid GOOGLE_SERVICE_ACCOUNT_JSON_B64: expected base64-encoded JSON in a single line.' + ); + } } function getCalendarClient() { From 5b08384f3de5d3ca25f553c5002090a6374e2bc2 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:32:45 +0100 Subject: [PATCH 113/143] Update twilio.js From 03aa20f8b86b81da80c11372637aee6d20697d8e Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:33:04 +0100 Subject: [PATCH 114/143] Update .env.example From 175dec397e479b16c04ccbb884972f1f3500cc9c Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:33:24 +0100 Subject: [PATCH 115/143] Update README.md From c4f9c56fad242afa77e3337afcc19cc057150f68 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:45:31 +0100 Subject: [PATCH 116/143] Update app.js --- app.js | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/app.js b/app.js index d47be25fc2..6859dab2ec 100644 --- a/app.js +++ b/app.js @@ -244,18 +244,29 @@ app.post('/voice/step', async (req, res) => { } }); - if (!session.data.whatsapp) { - throw new Error('Numero WhatsApp mancante'); + let whatsappSent = false; + if (session.data.whatsapp) { + try { + await sendWhatsAppMessage( + session.data.whatsapp, + `Ciao ${session.data.name}, prenotazione ricevuta per ${session.data.people} persone alle ${session.data.time}. Evento creato: ${event.htmlLink}` + ); + whatsappSent = true; + } catch (whatsappError) { + console.error('WhatsApp send error:', whatsappError); + } + } else { + console.warn('Missing WhatsApp number for call:', callSid); } - await sendWhatsAppMessage( - session.data.whatsapp, - `Ciao ${session.data.name}, prenotazione ricevuta per ${session.data.people} persone alle ${session.data.time}. Evento creato: ${event.htmlLink}` - ); - response.say({ language: 'it-IT' }, 'Prenotazione confermata. Ti ho inviato un messaggio WhatsApp. A presto!'); + if (whatsappSent) { + response.say({ language: 'it-IT' }, 'Prenotazione confermata. Ti ho inviato un messaggio WhatsApp. A presto!'); + } else { + response.say({ language: 'it-IT' }, 'Prenotazione confermata. A presto!'); + } } catch (error) { - console.error('Calendar/WhatsApp error:', error); - response.say({ language: 'it-IT' }, 'C’è un problema tecnico sul calendario. Ti ricontatteremo presto.'); + console.error('Calendar error:', error); + response.say({ language: 'it-IT' }, 'Ho registrato la prenotazione, ma c’è un problema tecnico sul calendario. Ti ricontatteremo presto.'); } sessions.delete(callSid); From d26eb348286ed3bac2814d6db5a8deed7303dc84 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:45:51 +0100 Subject: [PATCH 117/143] Update package.json From f04bdb11f3fb991d69afd2c0a56c02ffe317594d Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:46:09 +0100 Subject: [PATCH 118/143] Update schema.prisma From 29bf881a90942bad658a56e3ab8bff359d1c9743 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:46:28 +0100 Subject: [PATCH 119/143] Update seed.js From 5d61cb6dc4e6dc82569102714cc028e181331097 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:46:48 +0100 Subject: [PATCH 120/143] Update migrate-if-configured.js From 680ca3819b25714a4c6661753c6b1125db89341b Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:47:12 +0100 Subject: [PATCH 121/143] Update db.js From c4567579e3262eaf7e40b6aa7656cdd84dbc9172 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:47:29 +0100 Subject: [PATCH 122/143] Update calendar.js From 25e059dd724c3dc6f0deb928593a4068cd6c626a Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:47:48 +0100 Subject: [PATCH 123/143] Update twilio.js From a14b01288a4584172335d578cd61f0c4d94e770b Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:48:10 +0100 Subject: [PATCH 124/143] Update .env.example From 357e19cb73ed2857b38d78617293cfcc578c74ca Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 17:48:29 +0100 Subject: [PATCH 125/143] Update README.md From eb69b1bec6bca8c803d4f375a2c10ce5ce8eb577 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:46:18 +0100 Subject: [PATCH 126/143] Update app.js --- app.js | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/app.js b/app.js index 6859dab2ec..fb74292ff0 100644 --- a/app.js +++ b/app.js @@ -143,17 +143,36 @@ app.get('/debug/db', async (req, res) => { } }); -app.post('/debug/calendar-test', async (req, res) => { +// ======================= +// DEBUG CALENDAR TEST +// ======================= +app.get('/debug/calendar-test', async (req, res) => { try { - const event = await createBookingEvent({ - bookingKey: `debug-${Date.now()}`, - summary: 'Debug Calendar Test', - description: 'Evento di test creato dal debug endpoint', - startDateTimeISO: new Date().toISOString() + const { createBookingEvent: createDebugBookingEvent } = require('./lib/calendar'); + const bookingKey = 'DEBUG-CALENDAR-TEST'; + const dateISO = '2026-01-10'; + const time24 = '20:00'; + + const startDateTimeISO = buildStartDateTime(dateISO, time24); + const result = await createDebugBookingEvent({ + bookingKey, + summary: 'Test Calendar', + description: `Evento di test per ${bookingKey}`, + startDateTimeISO + }); + + res.json({ + ok: true, + message: 'Evento creato con successo', + result + }); + } catch (err) { + console.error('DEBUG CALENDAR ERROR:', err); + res.status(500).json({ + ok: false, + error: err.message, + details: err }); - res.json({ ok: true, eventId: event.id, htmlLink: event.htmlLink }); - } catch (error) { - res.status(500).json({ ok: false, error: error.message }); } }); From de2eb9bb974c55b47cebb045492b03e17a4b8976 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:46:48 +0100 Subject: [PATCH 127/143] Update package.json From 8e6dcbbe48d144f65b7f04a7342d45cdfa9aed9f Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:47:13 +0100 Subject: [PATCH 128/143] Update schema.prisma From 17d0a25af0af6d5e2d38b478b3b76545a59c7377 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:47:36 +0100 Subject: [PATCH 129/143] Update seed.js From 95ba89cbdabbcf094b8d9647f2f46bfece6e23ae Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:47:57 +0100 Subject: [PATCH 130/143] Update migrate-if-configured.js From 8ca8df4aa0ecbfb590a7aded4afd62c73828fba6 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:48:29 +0100 Subject: [PATCH 131/143] Update db.js From 2179711a767a7ff8c1ce6fa0d1f004794129b223 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:48:54 +0100 Subject: [PATCH 132/143] Update calendar.js --- lib/calendar.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/calendar.js b/lib/calendar.js index b05189445b..faf97ae45a 100644 --- a/lib/calendar.js +++ b/lib/calendar.js @@ -5,19 +5,18 @@ function getCredentials() { throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing'); } const rawEnv = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64.trim(); - const decoded = Buffer.from(rawEnv, 'base64').toString('utf8').trim(); + const unquoted = rawEnv.replace(/^"(.*)"$/, '$1'); + + if (unquoted.startsWith('{')) { + return JSON.parse(unquoted); + } + + const base64Payload = unquoted.replace(/\s+/g, ''); + const decoded = Buffer.from(base64Payload, 'base64').toString('utf8').trim(); try { return JSON.parse(decoded); } catch (error) { - if (rawEnv.startsWith('{')) { - try { - return JSON.parse(rawEnv); - } catch (rawError) { - throw rawError; - } - } - const firstBrace = decoded.indexOf('{'); const lastBrace = decoded.lastIndexOf('}'); if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { @@ -30,7 +29,7 @@ function getCredentials() { } throw new Error( - 'Invalid GOOGLE_SERVICE_ACCOUNT_JSON_B64: expected base64-encoded JSON in a single line.' + 'Invalid GOOGLE_SERVICE_ACCOUNT_JSON_B64: provide a single-line base64 value or raw JSON.' ); } } From a82d05177461380ff395ed4d512b6ae45a524a4d Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:49:16 +0100 Subject: [PATCH 133/143] Update twilio.js From f51c40efa733e563e13b199555ac70afc6d62e97 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:49:41 +0100 Subject: [PATCH 134/143] Update .env.example From 518db1fb20547eed277652bc3c215029da0d9eed Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 18:50:01 +0100 Subject: [PATCH 135/143] Update README.md From 2d164e628bd3590b59495128270599a9275f81f4 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:07:44 +0100 Subject: [PATCH 136/143] Update package.json --- package.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 93e9700cc5..2c036488c2 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,17 @@ { "name": "tuttibrilli-ai-backoffice", - "version": "0.1.0", + "version": "1.0.0", "private": true, "main": "app.js", "type": "commonjs", "scripts": { "start": "node app.js", - "dev": "node app.js", - "postinstall": "prisma generate", - "migrate": "prisma migrate deploy", - "migrate:if": "node scripts/migrate-if-configured.js", - "seed": "node prisma/seed.js" + "dev": "node app.js" }, "dependencies": { - "@prisma/client": "^5.18.0", "dotenv": "^16.4.5", "express": "^4.19.2", "googleapis": "^133.0.0", - "prisma": "^5.18.0", "twilio": "^4.23.0" } } From f35c30184575c11b154705ef11a00cc8dee47308 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:08:01 +0100 Subject: [PATCH 137/143] Update app.js --- app.js | 280 ++++++++------------------------------------------------- 1 file changed, 36 insertions(+), 244 deletions(-) diff --git a/app.js b/app.js index fb74292ff0..97fcfa3094 100644 --- a/app.js +++ b/app.js @@ -1,122 +1,53 @@ const express = require('express'); const dotenv = require('dotenv'); -const { getPrismaClient, ensureDefaultLocale, upsertBusinessDay } = require('./lib/db'); -const { createBookingEvent } = require('./lib/calendar'); -const { sendWhatsAppMessage, createOutboundCall } = require('./lib/twilio'); -const { twiml: TwilioTwiml } = require('twilio'); +const { createCalendarClient, createBookingEvent } = require('./lib/calendar'); +const { createVoiceRouter } = require('./lib/voiceFlow'); +const twilio = require('twilio'); dotenv.config(); const app = express(); -app.use(express.json()); app.use(express.urlencoded({ extended: false })); +app.use(express.json()); const REQUIRED_ENV = [ - 'DATABASE_URL', + 'BASE_URL', + 'PORT', 'GOOGLE_SERVICE_ACCOUNT_JSON_B64', 'GOOGLE_CALENDAR_ID', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'TWILIO_WHATSAPP_FROM', - 'BASE_URL', 'FORWARDING_ENABLED', 'HUMAN_FORWARD_TO' ]; -const sessions = new Map(); -const MAX_RETRIES = 3; -const VOICE_STEPS = [ - { key: 'name', prompt: 'Ciao! Come ti chiami?' }, - { key: 'date', prompt: 'Per quale data vuoi prenotare?' }, - { key: 'time', prompt: 'A che ora?' }, - { key: 'people', prompt: 'Per quante persone?' }, - { key: 'whatsapp', prompt: 'Qual è il tuo numero WhatsApp con prefisso internazionale?' } -]; - -function getSession(callSid) { - if (!sessions.has(callSid)) { - sessions.set(callSid, { - stepIndex: 0, - attempts: 0, - data: {} - }); - } - return sessions.get(callSid); -} - -function buildGatherResponse(prompt) { - const response = new TwilioTwiml.VoiceResponse(); - const gather = response.gather({ - input: 'speech', - language: 'it-IT', - speechTimeout: 'auto', - action: '/voice/step', - method: 'POST' - }); - gather.say({ language: 'it-IT' }, prompt); - response.say({ language: 'it-IT' }, 'Non ho ricevuto risposta. Riproviamo.'); - response.redirect({ method: 'POST' }, '/voice'); - return response; -} - -function buildTransferResponse() { - const response = new TwilioTwiml.VoiceResponse(); - if (process.env.FORWARDING_ENABLED === 'true' && process.env.HUMAN_FORWARD_TO) { - response.say({ language: 'it-IT' }, 'Ti trasferisco a un operatore.'); - response.dial({}, process.env.HUMAN_FORWARD_TO); - } else { - response.say({ language: 'it-IT' }, 'Spiacente, non riesco a comprendere la richiesta. Riprova più tardi.'); +function getTwilioClient() { + if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) { + return null; } - return response; + return twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); } -function normalizeDateInput(value) { - if (!value) return new Date().toISOString().slice(0, 10); - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return new Date().toISOString().slice(0, 10); +async function sendWhatsAppConfirmation(to, body) { + const client = getTwilioClient(); + if (!client) { + throw new Error('Twilio credentials missing'); } - return parsed.toISOString().slice(0, 10); -} - -function buildStartDateTime(dateInput, timeInput) { - const dateISO = normalizeDateInput(dateInput); - const time = (timeInput || '19:00').replace(/[^0-9:]/g, '') || '19:00'; - const dateTime = new Date(`${dateISO}T${time}`); - if (Number.isNaN(dateTime.getTime())) { - return new Date().toISOString(); + if (!process.env.TWILIO_WHATSAPP_FROM) { + throw new Error('TWILIO_WHATSAPP_FROM missing'); } - return dateTime.toISOString(); -} - -async function saveBookingIfConfigured(session, callSid) { - const prisma = getPrismaClient(); - if (!prisma) { - return null; + if (!to) { + throw new Error('Missing WhatsApp destination'); } - const locale = await ensureDefaultLocale(prisma); - const dateISO = normalizeDateInput(session.data.date); - const businessDay = await upsertBusinessDay(prisma, locale.id, dateISO); - - return prisma.booking.create({ - data: { - localeId: locale.id, - businessDayId: businessDay.id, - name: session.data.name || 'Cliente', - time24: session.data.time || '00:00', - people: Number.parseInt(session.data.people, 10) || 2, - status: 'REQUESTED', - whatsapp: session.data.whatsapp || null, - createdBy: callSid - } + return client.messages.create({ + from: process.env.TWILIO_WHATSAPP_FROM, + to: to.startsWith('whatsapp:') ? to : `whatsapp:${to}`, + body }); } -app.get('/', (req, res) => { - res.send('AI TuttiBrilli backend attivo'); -}); - app.get('/healthz', (req, res) => { res.json({ status: 'ok' }); }); @@ -129,168 +60,29 @@ app.get('/debug/env', (req, res) => { res.json({ status }); }); -app.get('/debug/db', async (req, res) => { - if (!process.env.DATABASE_URL) { - return res.status(400).json({ ok: false, error: 'DATABASE_URL missing' }); - } - try { - const prisma = getPrismaClient(); - const bookingCount = await prisma.booking.count(); - const businessDayCount = await prisma.businessDay.count(); - return res.json({ ok: true, bookingCount, businessDayCount }); - } catch (error) { - return res.status(500).json({ ok: false, error: error.message }); - } -}); - -// ======================= -// DEBUG CALENDAR TEST -// ======================= app.get('/debug/calendar-test', async (req, res) => { try { - const { createBookingEvent: createDebugBookingEvent } = require('./lib/calendar'); - const bookingKey = 'DEBUG-CALENDAR-TEST'; - const dateISO = '2026-01-10'; - const time24 = '20:00'; - - const startDateTimeISO = buildStartDateTime(dateISO, time24); - const result = await createDebugBookingEvent({ - bookingKey, + const calendar = createCalendarClient(); + const result = await createBookingEvent(calendar, { + bookingKey: 'DEBUG-CALENDAR-TEST', summary: 'Test Calendar', - description: `Evento di test per ${bookingKey}`, - startDateTimeISO - }); - - res.json({ - ok: true, - message: 'Evento creato con successo', - result - }); - } catch (err) { - console.error('DEBUG CALENDAR ERROR:', err); - res.status(500).json({ - ok: false, - error: err.message, - details: err + startDateTimeISO: new Date('2026-01-10T20:00:00+01:00').toISOString(), + durationMinutes: 120 }); - } -}); - -app.post('/debug/whatsapp-test', async (req, res) => { - const { to } = req.body; - if (!to) { - return res.status(400).json({ ok: false, error: 'Missing to' }); - } - try { - const message = await sendWhatsAppMessage(to, 'Messaggio WhatsApp di test da TuttiBrilli.'); - res.json({ ok: true, sid: message.sid }); - } catch (error) { - res.status(500).json({ ok: false, error: error.message }); - } -}); - -app.post('/debug/call-outbound', async (req, res) => { - const { to } = req.body; - if (!to) { - return res.status(400).json({ ok: false, error: 'Missing to' }); - } - try { - const call = await createOutboundCall(to); - res.json({ ok: true, sid: call.sid }); + res.json({ ok: true, message: 'Evento creato con successo', result }); } catch (error) { + console.error('DEBUG CALENDAR ERROR:', error); res.status(500).json({ ok: false, error: error.message }); } }); -app.post('/whatsapp/inbound', (req, res) => { - console.log('WhatsApp inbound payload:', req.body); - res.status(200).send('OK'); -}); - -app.post('/voice', (req, res) => { - const callSid = req.body.CallSid || 'unknown'; - const session = getSession(callSid); - const step = VOICE_STEPS[session.stepIndex]; - const response = buildGatherResponse(step.prompt); - res.type('text/xml').send(response.toString()); -}); - -app.post('/voice/step', async (req, res) => { - const callSid = req.body.CallSid || 'unknown'; - const speechResult = (req.body.SpeechResult || '').trim(); - const session = getSession(callSid); - - if (!speechResult) { - session.attempts += 1; - if (session.attempts >= MAX_RETRIES) { - const transferResponse = buildTransferResponse(); - sessions.delete(callSid); - return res.type('text/xml').send(transferResponse.toString()); - } - const retryResponse = buildGatherResponse('Non ho capito, puoi ripetere?'); - return res.type('text/xml').send(retryResponse.toString()); - } - - const step = VOICE_STEPS[session.stepIndex]; - session.data[step.key] = speechResult; - session.attempts = 0; - session.stepIndex += 1; - - if (session.stepIndex < VOICE_STEPS.length) { - const nextStep = VOICE_STEPS[session.stepIndex]; - const nextResponse = buildGatherResponse(nextStep.prompt); - return res.type('text/xml').send(nextResponse.toString()); - } - - const response = new TwilioTwiml.VoiceResponse(); - response.say({ language: 'it-IT' }, 'Perfetto, sto registrando la tua richiesta.'); - - let bookingRecord = null; - try { - bookingRecord = await saveBookingIfConfigured(session, callSid); - } catch (error) { - console.error('Booking save error:', error); - } - - try { - const event = await createBookingEvent({ - bookingKey: callSid, - summary: `Prenotazione ${session.data.name || 'Cliente'}`, - description: `Prenotazione per ${session.data.people || 'N/A'} persone alle ${session.data.time || 'N/A'}.`, - startDateTimeISO: buildStartDateTime(session.data.date, session.data.time), - metadata: { - bookingId: bookingRecord ? bookingRecord.id : null - } - }); - - let whatsappSent = false; - if (session.data.whatsapp) { - try { - await sendWhatsAppMessage( - session.data.whatsapp, - `Ciao ${session.data.name}, prenotazione ricevuta per ${session.data.people} persone alle ${session.data.time}. Evento creato: ${event.htmlLink}` - ); - whatsappSent = true; - } catch (whatsappError) { - console.error('WhatsApp send error:', whatsappError); - } - } else { - console.warn('Missing WhatsApp number for call:', callSid); - } - - if (whatsappSent) { - response.say({ language: 'it-IT' }, 'Prenotazione confermata. Ti ho inviato un messaggio WhatsApp. A presto!'); - } else { - response.say({ language: 'it-IT' }, 'Prenotazione confermata. A presto!'); - } - } catch (error) { - console.error('Calendar error:', error); - response.say({ language: 'it-IT' }, 'Ho registrato la prenotazione, ma c’è un problema tecnico sul calendario. Ti ricontatteremo presto.'); - } - - sessions.delete(callSid); - res.type('text/xml').send(response.toString()); -}); +app.use( + createVoiceRouter({ + createCalendarClient, + createBookingEvent, + sendWhatsAppConfirmation + }) +); const port = process.env.PORT || 3000; app.listen(port, () => { From 550e64788c0c9fc2cab03a3bcdb36e836a5b223f Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:08:28 +0100 Subject: [PATCH 138/143] Update calendar.js --- lib/calendar.js | 123 +++++++++++++++++++++--------------------------- 1 file changed, 54 insertions(+), 69 deletions(-) diff --git a/lib/calendar.js b/lib/calendar.js index faf97ae45a..a796d5898e 100644 --- a/lib/calendar.js +++ b/lib/calendar.js @@ -1,44 +1,24 @@ const { google } = require('googleapis'); -function getCredentials() { - if (!process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64) { +function parseServiceAccountJson() { + const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64; + if (!raw) { throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON_B64 missing'); } - const rawEnv = process.env.GOOGLE_SERVICE_ACCOUNT_JSON_B64.trim(); - const unquoted = rawEnv.replace(/^"(.*)"$/, '$1'); - - if (unquoted.startsWith('{')) { - return JSON.parse(unquoted); - } - - const base64Payload = unquoted.replace(/\s+/g, ''); - const decoded = Buffer.from(base64Payload, 'base64').toString('utf8').trim(); - - try { - return JSON.parse(decoded); - } catch (error) { - const firstBrace = decoded.indexOf('{'); - const lastBrace = decoded.lastIndexOf('}'); - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - const sliced = decoded.slice(firstBrace, lastBrace + 1); - try { - return JSON.parse(sliced); - } catch (sliceError) { - throw sliceError; - } - } - - throw new Error( - 'Invalid GOOGLE_SERVICE_ACCOUNT_JSON_B64: provide a single-line base64 value or raw JSON.' - ); + const trimmed = raw.trim(); + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed); } + const payload = trimmed.replace(/\s+/g, ''); + const decoded = Buffer.from(payload, 'base64').toString('utf8').trim(); + return JSON.parse(decoded); } -function getCalendarClient() { +function createCalendarClient() { if (!process.env.GOOGLE_CALENDAR_ID) { throw new Error('GOOGLE_CALENDAR_ID missing'); } - const credentials = getCredentials(); + const credentials = parseServiceAccountJson(); const auth = new google.auth.GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/calendar'] @@ -46,40 +26,48 @@ function getCalendarClient() { return google.calendar({ version: 'v3', auth }); } -async function findExistingEvent(calendar, bookingKey) { - const calendarId = process.env.GOOGLE_CALENDAR_ID; - const response = await calendar.events.list({ - calendarId, - privateExtendedProperty: `bookingKey=${bookingKey}`, - maxResults: 1 - }); - const [event] = response.data.items || []; - if (event) return event; - - const fallback = await calendar.events.list({ - calendarId, - q: bookingKey, - maxResults: 1 - }); - const [fallbackEvent] = fallback.data.items || []; - return fallbackEvent || null; +function assertEventPayload(event) { + if (!event.summary) { + throw new Error('Calendar event missing summary'); + } + if (!event.start || !event.start.dateTime || !event.start.timeZone) { + throw new Error('Calendar event missing start dateTime/timeZone'); + } + if (!event.end || !event.end.dateTime || !event.end.timeZone) { + throw new Error('Calendar event missing end dateTime/timeZone'); + } } -async function createBookingEvent({ bookingKey, summary, description, startDateTimeISO, metadata }) { - const calendar = getCalendarClient(); +async function createBookingEvent(calendar, payload) { + if (!calendar) { + throw new Error('Calendar client missing'); + } const calendarId = process.env.GOOGLE_CALENDAR_ID; - const existing = await findExistingEvent(calendar, bookingKey); - if (existing) { - return existing; + if (!calendarId) { + throw new Error('GOOGLE_CALENDAR_ID missing'); + } + if (!payload || !payload.bookingKey) { + throw new Error('Missing bookingKey'); + } + if (!payload.summary) { + throw new Error('Missing summary'); + } + if (!payload.startDateTimeISO) { + throw new Error('Missing startDateTimeISO'); } - const durationMinutes = Number.parseInt(process.env.DEFAULT_EVENT_DURATION_MINUTES || '120', 10); - const start = new Date(startDateTimeISO); + const start = new Date(payload.startDateTimeISO); + if (Number.isNaN(start.getTime())) { + throw new Error('Invalid startDateTimeISO'); + } + const durationMinutes = Number.isFinite(payload.durationMinutes) + ? payload.durationMinutes + : 120; const end = new Date(start.getTime() + durationMinutes * 60 * 1000); const event = { - summary: summary || 'Prenotazione', - description: `${description || ''}\nBookingKey: ${bookingKey}`, + summary: payload.summary, + description: payload.description || '', start: { dateTime: start.toISOString(), timeZone: 'Europe/Rome' @@ -90,24 +78,21 @@ async function createBookingEvent({ bookingKey, summary, description, startDateT }, extendedProperties: { private: { - bookingKey, - ...(metadata || {}) + bookingKey: payload.bookingKey } } }; - try { - const response = await calendar.events.insert({ - calendarId, - requestBody: event - }); - return response.data; - } catch (error) { - console.error('Calendar insert error:', error.response?.data || error.message); - throw error; - } + assertEventPayload(event); + + const response = await calendar.events.insert({ + calendarId, + requestBody: event + }); + return response.data; } module.exports = { + createCalendarClient, createBookingEvent }; From f728f1b2f8594a269191f82c107846c33e7bbf50 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:09:14 +0100 Subject: [PATCH 139/143] Create voiceFlow.js --- lib/voiceFlow.js | 187 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 lib/voiceFlow.js diff --git a/lib/voiceFlow.js b/lib/voiceFlow.js new file mode 100644 index 0000000000..88b0a2779b --- /dev/null +++ b/lib/voiceFlow.js @@ -0,0 +1,187 @@ +const express = require('express'); +const { twiml: TwilioTwiml } = require('twilio'); + +const MAX_RETRIES = 2; +const STEPS = [ + { key: 'name', prompt: 'Ciao! Come ti chiami?' }, + { key: 'date', prompt: 'Per quale data vuoi prenotare?' }, + { key: 'time', prompt: 'A che ora?' }, + { key: 'people', prompt: 'Per quante persone?' }, + { key: 'whatsapp', prompt: 'Qual è il tuo numero WhatsApp con prefisso internazionale?' } +]; + +function buildGather(prompt) { + const response = new TwilioTwiml.VoiceResponse(); + const gather = response.gather({ + input: 'speech', + language: 'it-IT', + speechTimeout: 'auto', + action: '/voice/step', + method: 'POST' + }); + gather.say({ language: 'it-IT' }, prompt); + response.say({ language: 'it-IT' }, 'Non ho ricevuto risposta. Riproviamo.'); + response.redirect({ method: 'POST' }, '/voice'); + return response; +} + +function buildTransfer() { + const response = new TwilioTwiml.VoiceResponse(); + if (process.env.FORWARDING_ENABLED === 'true' && process.env.HUMAN_FORWARD_TO) { + response.say({ language: 'it-IT' }, 'Ti trasferisco a un operatore.'); + response.dial({}, process.env.HUMAN_FORWARD_TO); + } else { + response.say( + { language: 'it-IT' }, + 'Spiacente, non riesco a comprendere la richiesta. Riprova più tardi.' + ); + } + return response; +} + +function normalizeDateInput(value) { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed.toISOString().slice(0, 10); +} + +function normalizeTimeInput(value) { + if (!value) return null; + const match = value.match(/(\d{1,2})[:.]?(\d{2})?/); + if (!match) return null; + const hours = match[1].padStart(2, '0'); + const minutes = (match[2] || '00').padStart(2, '0'); + return `${hours}:${minutes}`; +} + +function normalizePeopleInput(value) { + if (!value) return null; + const match = value.match(/(\d+)/); + if (!match) return null; + const parsed = Number.parseInt(match[1], 10); + return Number.isNaN(parsed) ? null : parsed; +} + +function normalizeWhatsAppInput(value) { + if (!value) return null; + const digits = value.replace(/[^\d+]/g, ''); + if (!digits) return null; + return digits; +} + +function ensureSession(store, callSid) { + if (!store.has(callSid)) { + store.set(callSid, { stepIndex: 0, attempts: 0, data: {} }); + } + return store.get(callSid); +} + +function isComplete(data) { + return data.name && data.date && data.time && data.people && data.whatsapp; +} + +function buildStartDateTimeISO(dateISO, time24) { + if (!dateISO || !time24) return null; + const dateTime = new Date(`${dateISO}T${time24}:00`); + if (Number.isNaN(dateTime.getTime())) { + return null; + } + return dateTime.toISOString(); +} + +function createVoiceRouter({ createCalendarClient, createBookingEvent, sendWhatsAppConfirmation }) { + const router = express.Router(); + const sessions = new Map(); + + router.post('/voice', (req, res) => { + const callSid = req.body.CallSid || 'unknown'; + const session = ensureSession(sessions, callSid); + const step = STEPS[session.stepIndex]; + const response = buildGather(step.prompt); + res.type('text/xml').send(response.toString()); + }); + + router.post('/voice/step', async (req, res) => { + const callSid = req.body.CallSid || 'unknown'; + const speechResult = (req.body.SpeechResult || '').trim(); + const session = ensureSession(sessions, callSid); + + if (!speechResult) { + session.attempts += 1; + if (session.attempts > MAX_RETRIES) { + const transfer = buildTransfer(); + sessions.delete(callSid); + return res.type('text/xml').send(transfer.toString()); + } + const retry = buildGather('Non ho capito, puoi ripetere?'); + return res.type('text/xml').send(retry.toString()); + } + + const step = STEPS[session.stepIndex]; + let value = speechResult; + if (step.key === 'date') value = normalizeDateInput(speechResult); + if (step.key === 'time') value = normalizeTimeInput(speechResult); + if (step.key === 'people') value = normalizePeopleInput(speechResult); + if (step.key === 'whatsapp') value = normalizeWhatsAppInput(speechResult); + + if (!value) { + session.attempts += 1; + const retry = buildGather(`Non ho capito. ${step.prompt}`); + return res.type('text/xml').send(retry.toString()); + } + + session.data[step.key] = value; + session.stepIndex += 1; + session.attempts = 0; + + if (!isComplete(session.data)) { + const nextStep = STEPS[session.stepIndex]; + const response = buildGather(nextStep.prompt); + return res.type('text/xml').send(response.toString()); + } + + const response = new TwilioTwiml.VoiceResponse(); + response.say({ language: 'it-IT' }, 'Perfetto, sto registrando la tua prenotazione.'); + + try { + const calendar = createCalendarClient(); + const startDateTimeISO = buildStartDateTimeISO(session.data.date, session.data.time); + if (!startDateTimeISO) { + throw new Error('Data o orario non validi'); + } + + const event = await createBookingEvent(calendar, { + bookingKey: callSid, + summary: `Prenotazione ${session.data.name}`, + description: `Prenotazione per ${session.data.people} persone`, + startDateTimeISO, + durationMinutes: 120 + }); + + await sendWhatsAppConfirmation( + session.data.whatsapp, + `Ciao ${session.data.name}, prenotazione confermata per ${session.data.people} persone alle ${session.data.time}. Evento: ${event.htmlLink}` + ); + + response.say({ language: 'it-IT' }, 'Prenotazione confermata. Ti ho inviato un messaggio WhatsApp. A presto!'); + } catch (error) { + console.error('VOICE FLOW ERROR:', error); + response.say( + { language: 'it-IT' }, + 'C’è un problema tecnico con il calendario. Riprova più tardi.' + ); + } + + sessions.delete(callSid); + return res.type('text/xml').send(response.toString()); + }); + + return router; +} + +module.exports = { + createVoiceRouter +}; From 33e407fed6e23e9f1c2f31676bf7b4c499b0df99 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:27:28 +0100 Subject: [PATCH 140/143] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c036488c2..490b8b2f1d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "tuttibrilli-ai-backoffice", + "name": "tuttibrilli-voice-backend", "version": "1.0.0", "private": true, "main": "app.js", From 6083456ba43ffcf6c32785a1d00a63e634871229 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:29:51 +0100 Subject: [PATCH 141/143] Update app.js --- app.js | 66 +--------------------------------------------------------- 1 file changed, 1 insertion(+), 65 deletions(-) diff --git a/app.js b/app.js index 97fcfa3094..feb3086c3c 100644 --- a/app.js +++ b/app.js @@ -2,7 +2,6 @@ const express = require('express'); const dotenv = require('dotenv'); const { createCalendarClient, createBookingEvent } = require('./lib/calendar'); const { createVoiceRouter } = require('./lib/voiceFlow'); -const twilio = require('twilio'); dotenv.config(); @@ -10,77 +9,14 @@ const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); -const REQUIRED_ENV = [ - 'BASE_URL', - 'PORT', - 'GOOGLE_SERVICE_ACCOUNT_JSON_B64', - 'GOOGLE_CALENDAR_ID', - 'TWILIO_ACCOUNT_SID', - 'TWILIO_AUTH_TOKEN', - 'TWILIO_WHATSAPP_FROM', - 'FORWARDING_ENABLED', - 'HUMAN_FORWARD_TO' -]; - -function getTwilioClient() { - if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) { - return null; - } - return twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); -} - -async function sendWhatsAppConfirmation(to, body) { - const client = getTwilioClient(); - if (!client) { - throw new Error('Twilio credentials missing'); - } - if (!process.env.TWILIO_WHATSAPP_FROM) { - throw new Error('TWILIO_WHATSAPP_FROM missing'); - } - if (!to) { - throw new Error('Missing WhatsApp destination'); - } - - return client.messages.create({ - from: process.env.TWILIO_WHATSAPP_FROM, - to: to.startsWith('whatsapp:') ? to : `whatsapp:${to}`, - body - }); -} - app.get('/healthz', (req, res) => { res.json({ status: 'ok' }); }); -app.get('/debug/env', (req, res) => { - const status = REQUIRED_ENV.reduce((acc, key) => { - acc[key] = Boolean(process.env[key]); - return acc; - }, {}); - res.json({ status }); -}); - -app.get('/debug/calendar-test', async (req, res) => { - try { - const calendar = createCalendarClient(); - const result = await createBookingEvent(calendar, { - bookingKey: 'DEBUG-CALENDAR-TEST', - summary: 'Test Calendar', - startDateTimeISO: new Date('2026-01-10T20:00:00+01:00').toISOString(), - durationMinutes: 120 - }); - res.json({ ok: true, message: 'Evento creato con successo', result }); - } catch (error) { - console.error('DEBUG CALENDAR ERROR:', error); - res.status(500).json({ ok: false, error: error.message }); - } -}); - app.use( createVoiceRouter({ createCalendarClient, - createBookingEvent, - sendWhatsAppConfirmation + createBookingEvent }) ); From 6da1a20329f6e7fd520674a83ad1a31e83aa3be7 Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:30:16 +0100 Subject: [PATCH 142/143] Update calendar.js --- lib/calendar.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/calendar.js b/lib/calendar.js index a796d5898e..451ae39976 100644 --- a/lib/calendar.js +++ b/lib/calendar.js @@ -36,6 +36,9 @@ function assertEventPayload(event) { if (!event.end || !event.end.dateTime || !event.end.timeZone) { throw new Error('Calendar event missing end dateTime/timeZone'); } + if (!event.extendedProperties || !event.extendedProperties.private?.bookingKey) { + throw new Error('Calendar event missing bookingKey'); + } } async function createBookingEvent(calendar, payload) { @@ -56,6 +59,7 @@ async function createBookingEvent(calendar, payload) { throw new Error('Missing startDateTimeISO'); } + const timeZone = process.env.GOOGLE_CALENDAR_TZ || 'Europe/Rome'; const start = new Date(payload.startDateTimeISO); if (Number.isNaN(start.getTime())) { throw new Error('Invalid startDateTimeISO'); @@ -70,11 +74,11 @@ async function createBookingEvent(calendar, payload) { description: payload.description || '', start: { dateTime: start.toISOString(), - timeZone: 'Europe/Rome' + timeZone }, end: { dateTime: end.toISOString(), - timeZone: 'Europe/Rome' + timeZone }, extendedProperties: { private: { From d0f90fa45ac35b9e8f0142e2a0935b12789f80ff Mon Sep 17 00:00:00 2001 From: marcochimenti82-gif Date: Mon, 5 Jan 2026 19:30:46 +0100 Subject: [PATCH 143/143] Update voiceFlow.js --- lib/voiceFlow.js | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/lib/voiceFlow.js b/lib/voiceFlow.js index 88b0a2779b..2656739299 100644 --- a/lib/voiceFlow.js +++ b/lib/voiceFlow.js @@ -4,10 +4,9 @@ const { twiml: TwilioTwiml } = require('twilio'); const MAX_RETRIES = 2; const STEPS = [ { key: 'name', prompt: 'Ciao! Come ti chiami?' }, - { key: 'date', prompt: 'Per quale data vuoi prenotare?' }, + { key: 'date', prompt: 'Per quale giorno vuoi prenotare?' }, { key: 'time', prompt: 'A che ora?' }, - { key: 'people', prompt: 'Per quante persone?' }, - { key: 'whatsapp', prompt: 'Qual è il tuo numero WhatsApp con prefisso internazionale?' } + { key: 'people', prompt: 'Per quante persone?' } ]; function buildGather(prompt) { @@ -27,13 +26,13 @@ function buildGather(prompt) { function buildTransfer() { const response = new TwilioTwiml.VoiceResponse(); - if (process.env.FORWARDING_ENABLED === 'true' && process.env.HUMAN_FORWARD_TO) { + if (process.env.HUMAN_FORWARD_TO) { response.say({ language: 'it-IT' }, 'Ti trasferisco a un operatore.'); response.dial({}, process.env.HUMAN_FORWARD_TO); } else { response.say( { language: 'it-IT' }, - 'Spiacente, non riesco a comprendere la richiesta. Riprova più tardi.' + 'Spiacente, c’è un problema tecnico. Riprova più tardi.' ); } return response; @@ -65,13 +64,6 @@ function normalizePeopleInput(value) { return Number.isNaN(parsed) ? null : parsed; } -function normalizeWhatsAppInput(value) { - if (!value) return null; - const digits = value.replace(/[^\d+]/g, ''); - if (!digits) return null; - return digits; -} - function ensureSession(store, callSid) { if (!store.has(callSid)) { store.set(callSid, { stepIndex: 0, attempts: 0, data: {} }); @@ -80,7 +72,7 @@ function ensureSession(store, callSid) { } function isComplete(data) { - return data.name && data.date && data.time && data.people && data.whatsapp; + return data.name && data.date && data.time && data.people; } function buildStartDateTimeISO(dateISO, time24) { @@ -92,7 +84,7 @@ function buildStartDateTimeISO(dateISO, time24) { return dateTime.toISOString(); } -function createVoiceRouter({ createCalendarClient, createBookingEvent, sendWhatsAppConfirmation }) { +function createVoiceRouter({ createCalendarClient, createBookingEvent }) { const router = express.Router(); const sessions = new Map(); @@ -125,7 +117,6 @@ function createVoiceRouter({ createCalendarClient, createBookingEvent, sendWhats if (step.key === 'date') value = normalizeDateInput(speechResult); if (step.key === 'time') value = normalizeTimeInput(speechResult); if (step.key === 'people') value = normalizePeopleInput(speechResult); - if (step.key === 'whatsapp') value = normalizeWhatsAppInput(speechResult); if (!value) { session.attempts += 1; @@ -144,7 +135,11 @@ function createVoiceRouter({ createCalendarClient, createBookingEvent, sendWhats } const response = new TwilioTwiml.VoiceResponse(); - response.say({ language: 'it-IT' }, 'Perfetto, sto registrando la tua prenotazione.'); + response.say({ language: 'it-IT' }, 'Perfetto, riepilogo la prenotazione.'); + response.say( + { language: 'it-IT' }, + `Nome ${session.data.name}, data ${session.data.date}, ora ${session.data.time}, per ${session.data.people} persone.` + ); try { const calendar = createCalendarClient(); @@ -153,7 +148,7 @@ function createVoiceRouter({ createCalendarClient, createBookingEvent, sendWhats throw new Error('Data o orario non validi'); } - const event = await createBookingEvent(calendar, { + await createBookingEvent(calendar, { bookingKey: callSid, summary: `Prenotazione ${session.data.name}`, description: `Prenotazione per ${session.data.people} persone`, @@ -161,18 +156,15 @@ function createVoiceRouter({ createCalendarClient, createBookingEvent, sendWhats durationMinutes: 120 }); - await sendWhatsAppConfirmation( - session.data.whatsapp, - `Ciao ${session.data.name}, prenotazione confermata per ${session.data.people} persone alle ${session.data.time}. Evento: ${event.htmlLink}` - ); - - response.say({ language: 'it-IT' }, 'Prenotazione confermata. Ti ho inviato un messaggio WhatsApp. A presto!'); + response.say({ language: 'it-IT' }, 'Prenotazione confermata. A presto!'); } catch (error) { console.error('VOICE FLOW ERROR:', error); response.say( { language: 'it-IT' }, - 'C’è un problema tecnico con il calendario. Riprova più tardi.' + 'C’è un problema tecnico con il calendario. Per favore, contatta un operatore.' ); + const transfer = buildTransfer(); + return res.type('text/xml').send(transfer.toString()); } sessions.delete(callSid);