|
|
|
@@ -0,0 +1,372 @@
|
|
|
|
|
import { Prisma } from "@prisma/client";
|
|
|
|
|
import { prisma } from "./prisma";
|
|
|
|
|
import { obtenerSlotsDisponibles } from "./scheduling";
|
|
|
|
|
import { crearTurno } from "./turnos";
|
|
|
|
|
import { normalizarTelefono } from "./telefono";
|
|
|
|
|
|
|
|
|
|
// ── Tipos de respuesta que el bot le pide al canal (F0 los traduce a la API de Meta) ──
|
|
|
|
|
export type BotonBot = { id: string; titulo: string };
|
|
|
|
|
export type RespuestaBot =
|
|
|
|
|
| { tipo: "texto"; texto: string }
|
|
|
|
|
| { tipo: "botones"; texto: string; botones: BotonBot[] };
|
|
|
|
|
|
|
|
|
|
// ── Estado que se persiste en ConversacionWhatsapp.datos ──
|
|
|
|
|
interface OpcionMenu {
|
|
|
|
|
letra: string;
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
}
|
|
|
|
|
interface DatosConv {
|
|
|
|
|
nombre?: string;
|
|
|
|
|
apellido?: string;
|
|
|
|
|
jefeMecanicoId?: string;
|
|
|
|
|
jefeNombre?: string;
|
|
|
|
|
tipoServicioId?: string;
|
|
|
|
|
tipoNombre?: string;
|
|
|
|
|
duracionHoras?: number;
|
|
|
|
|
slotsOffset?: number;
|
|
|
|
|
slotInicio?: string; // ISO del slot elegido
|
|
|
|
|
slotLabel?: string;
|
|
|
|
|
opciones?: OpcionMenu[]; // opciones mostradas en el paso actual (letra → id)
|
|
|
|
|
intentosEmail?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const EXPIRA_MIN = 30;
|
|
|
|
|
const SLOTS_POR_PAGINA = 8;
|
|
|
|
|
const LETRAS = "abcdefghijklmnopqrstuvwxyz";
|
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
|
|
|
|
|
const SI = ["si", "sí", "s", "dale", "ok", "confirmo", "confirmar"];
|
|
|
|
|
const NO = ["no", "n", "cancelar", "cancela"];
|
|
|
|
|
const MAS = ["mas", "más", "+", "ver mas", "ver más"];
|
|
|
|
|
|
|
|
|
|
// ── Helpers de presentación ──
|
|
|
|
|
function formatSlot(fecha: Date): string {
|
|
|
|
|
return fecha.toLocaleString("es-AR", {
|
|
|
|
|
timeZone: "America/Argentina/Buenos_Aires",
|
|
|
|
|
weekday: "short",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listaConLetras(items: { label: string }[]): { texto: string; opciones: OpcionMenu[] } {
|
|
|
|
|
const opciones: OpcionMenu[] = [];
|
|
|
|
|
const lineas = items.map((it, i) => {
|
|
|
|
|
const letra = LETRAS[i] ?? String(i + 1);
|
|
|
|
|
opciones.push({ letra, id: "", label: it.label });
|
|
|
|
|
return `${letra}) ${it.label}`;
|
|
|
|
|
});
|
|
|
|
|
return { texto: lineas.join("\n"), opciones };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function elegirOpcion(datos: DatosConv, input: string): OpcionMenu | undefined {
|
|
|
|
|
const t = input.trim().toLowerCase();
|
|
|
|
|
return (datos.opciones ?? []).find(
|
|
|
|
|
(o) => o.id.toLowerCase() === t || o.letra.toLowerCase() === t || o.label.toLowerCase() === t
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Construcción de pasos reutilizables ──
|
|
|
|
|
async function pasoElegirMecanico(): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
|
|
|
|
|
const jefes = await prisma.jefeMecanico.findMany({
|
|
|
|
|
where: { activo: true },
|
|
|
|
|
orderBy: { nombre: "asc" },
|
|
|
|
|
select: { id: true, nombre: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (jefes.length === 0)
|
|
|
|
|
return {
|
|
|
|
|
resp: [{ tipo: "texto", texto: "No hay mecánicos disponibles en este momento. Probá más tarde." }],
|
|
|
|
|
datos: {},
|
|
|
|
|
paso: "INICIO",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const opciones: OpcionMenu[] = jefes.map((j, i) => ({ letra: String(i + 1), id: j.id, label: j.nombre }));
|
|
|
|
|
const botones: BotonBot[] = jefes.slice(0, 3).map((j) => ({ id: j.id, titulo: j.nombre.slice(0, 20) }));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
resp: [{ tipo: "botones", texto: "¿Con qué mecánico querés atenderte?", botones }],
|
|
|
|
|
datos: { opciones },
|
|
|
|
|
paso: "ELEGIR_MECANICO",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pasoElegirTrabajo(datos: DatosConv): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
|
|
|
|
|
const servicios = await prisma.tipoServicio.findMany({
|
|
|
|
|
where: { jefeMecanicoId: datos.jefeMecanicoId, activo: true },
|
|
|
|
|
orderBy: { nombre: "asc" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (servicios.length === 0) {
|
|
|
|
|
const m = await pasoElegirMecanico();
|
|
|
|
|
return {
|
|
|
|
|
resp: [{ tipo: "texto", texto: `${datos.jefeNombre} todavía no cargó trabajos. Elegí otro mecánico.` }, ...m.resp],
|
|
|
|
|
datos: m.datos,
|
|
|
|
|
paso: m.paso,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { texto, opciones } = listaConLetras(
|
|
|
|
|
servicios.map((s) => ({ label: `${s.nombre} (${s.duracionHoras}h)` }))
|
|
|
|
|
);
|
|
|
|
|
// Guardar el id real de cada trabajo en las opciones
|
|
|
|
|
servicios.forEach((s, i) => (opciones[i].id = s.id));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
resp: [
|
|
|
|
|
{
|
|
|
|
|
tipo: "texto",
|
|
|
|
|
texto: `Trabajos de ${datos.jefeNombre}:\n\n${texto}\n\nRespondé con la letra de la opción.`,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
datos: { ...datos, opciones },
|
|
|
|
|
paso: "ELEGIR_TRABAJO",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pasoMostrarHorarios(
|
|
|
|
|
datos: DatosConv,
|
|
|
|
|
offset: number
|
|
|
|
|
): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
|
|
|
|
|
const slots = await obtenerSlotsDisponibles(datos.jefeMecanicoId!, datos.duracionHoras ?? 1, 30, 200);
|
|
|
|
|
|
|
|
|
|
if (slots.length === 0)
|
|
|
|
|
return {
|
|
|
|
|
resp: [
|
|
|
|
|
{ tipo: "texto", texto: "No hay horarios disponibles en los próximos días. Probá más tarde. 🙏" },
|
|
|
|
|
],
|
|
|
|
|
datos: {},
|
|
|
|
|
paso: "INICIO",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const off = offset >= slots.length ? 0 : offset;
|
|
|
|
|
const pagina = slots.slice(off, off + SLOTS_POR_PAGINA);
|
|
|
|
|
|
|
|
|
|
const opciones: OpcionMenu[] = pagina.map((s, i) => ({
|
|
|
|
|
letra: LETRAS[i] ?? String(i + 1),
|
|
|
|
|
id: s.fechaInicio.toISOString(),
|
|
|
|
|
label: formatSlot(s.fechaInicio),
|
|
|
|
|
}));
|
|
|
|
|
const lista = opciones.map((o) => `${o.letra}) ${o.label}`).join("\n");
|
|
|
|
|
const hayMas = off + SLOTS_POR_PAGINA < slots.length;
|
|
|
|
|
|
|
|
|
|
let texto = `Horarios disponibles con ${datos.jefeNombre} para ${datos.tipoNombre}:\n\n${lista}\n\nRespondé con la letra.`;
|
|
|
|
|
if (hayMas) texto += `\nEscribí *más* para ver otros horarios.`;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
resp: [{ tipo: "texto", texto }],
|
|
|
|
|
datos: { ...datos, opciones, slotsOffset: off },
|
|
|
|
|
paso: "ELEGIR_HORARIO",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Persistencia ──
|
|
|
|
|
async function guardar(
|
|
|
|
|
convId: string,
|
|
|
|
|
paso: string,
|
|
|
|
|
datos: DatosConv,
|
|
|
|
|
resp: RespuestaBot[],
|
|
|
|
|
clienteId?: string | null
|
|
|
|
|
): Promise<RespuestaBot[]> {
|
|
|
|
|
await prisma.conversacionWhatsapp.update({
|
|
|
|
|
where: { id: convId },
|
|
|
|
|
data: {
|
|
|
|
|
paso,
|
|
|
|
|
datos: datos as unknown as Prisma.InputJsonValue,
|
|
|
|
|
...(clienteId !== undefined && { clienteId }),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return resp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BIENVENIDA = "¡Hola! 👋 Soy el asistente del *Taller MG*. Te ayudo a sacar un turno.";
|
|
|
|
|
|
|
|
|
|
// ── Entrada principal ──
|
|
|
|
|
export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Promise<RespuestaBot[]> {
|
|
|
|
|
const telefono = normalizarTelefono(telefonoRaw);
|
|
|
|
|
const texto = (textoRaw ?? "").trim();
|
|
|
|
|
const lower = texto.toLowerCase();
|
|
|
|
|
|
|
|
|
|
const conv = await prisma.conversacionWhatsapp.upsert({
|
|
|
|
|
where: { telefono },
|
|
|
|
|
create: { telefono },
|
|
|
|
|
update: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let paso = conv.paso;
|
|
|
|
|
let datos = (conv.datos ?? {}) as DatosConv;
|
|
|
|
|
|
|
|
|
|
// Expiración: si la charla quedó a mitad y pasó el tiempo, se reinicia.
|
|
|
|
|
const vencida = Date.now() - new Date(conv.updatedAt).getTime() > EXPIRA_MIN * 60 * 1000;
|
|
|
|
|
if (paso !== "INICIO" && vencida) {
|
|
|
|
|
paso = "INICIO";
|
|
|
|
|
datos = {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Comando global: cancelar
|
|
|
|
|
if (NO.includes(lower) && paso !== "CONFIRMAR" && paso !== "INICIO") {
|
|
|
|
|
return guardar(conv.id, "INICIO", {}, [
|
|
|
|
|
{ tipo: "texto", texto: "Listo, cancelé la operación. Escribime cuando quieras para empezar de nuevo." },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (paso) {
|
|
|
|
|
case "INICIO": {
|
|
|
|
|
const cliente = await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });
|
|
|
|
|
if (cliente) {
|
|
|
|
|
if (cliente.email) {
|
|
|
|
|
return guardar(conv.id, "VERIFICAR_EMAIL", { intentosEmail: 0 }, [
|
|
|
|
|
{ tipo: "texto", texto: BIENVENIDA },
|
|
|
|
|
{ tipo: "texto", texto: "Para confirmar tu identidad, escribí el *email* que tenés registrado." },
|
|
|
|
|
], cliente.id);
|
|
|
|
|
}
|
|
|
|
|
// Cliente sin email registrado → no hay con qué verificar, se avanza directo.
|
|
|
|
|
const m = await pasoElegirMecanico();
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: BIENVENIDA }, ...m.resp], cliente.id);
|
|
|
|
|
}
|
|
|
|
|
// Cliente nuevo → alta.
|
|
|
|
|
return guardar(conv.id, "ALTA_NOMBRE", {}, [
|
|
|
|
|
{ tipo: "texto", texto: BIENVENIDA },
|
|
|
|
|
{ tipo: "texto", texto: "No te tengo registrado. ¿Cuál es tu *nombre*?" },
|
|
|
|
|
], null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "VERIFICAR_EMAIL": {
|
|
|
|
|
const cliente = conv.clienteId
|
|
|
|
|
? await prisma.cliente.findUnique({ where: { id: conv.clienteId } })
|
|
|
|
|
: await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });
|
|
|
|
|
if (cliente?.email && lower === cliente.email.toLowerCase()) {
|
|
|
|
|
const m = await pasoElegirMecanico();
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, m.resp, cliente.id);
|
|
|
|
|
}
|
|
|
|
|
const intentos = (datos.intentosEmail ?? 0) + 1;
|
|
|
|
|
if (intentos >= 3)
|
|
|
|
|
return guardar(conv.id, "INICIO", {}, [
|
|
|
|
|
{ tipo: "texto", texto: "El email no coincide. Por seguridad cerramos acá; comunicate con el taller. 🙏" },
|
|
|
|
|
]);
|
|
|
|
|
return guardar(conv.id, "VERIFICAR_EMAIL", { ...datos, intentosEmail: intentos }, [
|
|
|
|
|
{ tipo: "texto", texto: "Ese email no coincide con el registrado. Probá de nuevo." },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "ALTA_NOMBRE": {
|
|
|
|
|
if (!texto) return guardar(conv.id, "ALTA_NOMBRE", datos, [{ tipo: "texto", texto: "Decime tu *nombre*, por favor." }]);
|
|
|
|
|
return guardar(conv.id, "ALTA_APELLIDO", { ...datos, nombre: texto }, [
|
|
|
|
|
{ tipo: "texto", texto: `Gracias, ${texto}. ¿Cuál es tu *apellido*?` },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "ALTA_APELLIDO": {
|
|
|
|
|
if (!texto) return guardar(conv.id, "ALTA_APELLIDO", datos, [{ tipo: "texto", texto: "Decime tu *apellido*, por favor." }]);
|
|
|
|
|
return guardar(conv.id, "ALTA_EMAIL", { ...datos, apellido: texto }, [
|
|
|
|
|
{ tipo: "texto", texto: "Por último, ¿cuál es tu *email*?" },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "ALTA_EMAIL": {
|
|
|
|
|
if (!EMAIL_RE.test(texto))
|
|
|
|
|
return guardar(conv.id, "ALTA_EMAIL", datos, [{ tipo: "texto", texto: "Ese email no parece válido. Escribilo de nuevo." }]);
|
|
|
|
|
const existente = await prisma.cliente.findUnique({ where: { email: texto.toLowerCase() } });
|
|
|
|
|
if (existente)
|
|
|
|
|
return guardar(conv.id, "ALTA_EMAIL", datos, [
|
|
|
|
|
{ tipo: "texto", texto: "Ese email ya está registrado con otro número. Escribí otro o comunicate con el taller." },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const cliente = await prisma.cliente.create({
|
|
|
|
|
data: {
|
|
|
|
|
nombre: datos.nombre ?? "",
|
|
|
|
|
apellido: datos.apellido ?? "",
|
|
|
|
|
email: texto.toLowerCase(),
|
|
|
|
|
telefono,
|
|
|
|
|
telefonoNorm: telefono,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const m = await pasoElegirMecanico();
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "¡Listo, quedaste registrado! ✅" }, ...m.resp], cliente.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "ELEGIR_MECANICO": {
|
|
|
|
|
const op = elegirOpcion(datos, texto);
|
|
|
|
|
if (!op) {
|
|
|
|
|
const m = await pasoElegirMecanico();
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "No entendí. Elegí un mecánico:" }, ...m.resp]);
|
|
|
|
|
}
|
|
|
|
|
const m = await pasoElegirTrabajo({ jefeMecanicoId: op.id, jefeNombre: op.label });
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, m.resp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "ELEGIR_TRABAJO": {
|
|
|
|
|
const op = elegirOpcion(datos, texto);
|
|
|
|
|
if (!op)
|
|
|
|
|
return guardar(conv.id, "ELEGIR_TRABAJO", datos, [{ tipo: "texto", texto: "No entendí. Respondé con la letra del trabajo." }]);
|
|
|
|
|
const servicio = await prisma.tipoServicio.findUnique({ where: { id: op.id } });
|
|
|
|
|
if (!servicio || !servicio.activo) {
|
|
|
|
|
const m = await pasoElegirTrabajo(datos);
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "Ese trabajo ya no está disponible." }, ...m.resp]);
|
|
|
|
|
}
|
|
|
|
|
const nuevosDatos: DatosConv = {
|
|
|
|
|
jefeMecanicoId: datos.jefeMecanicoId,
|
|
|
|
|
jefeNombre: datos.jefeNombre,
|
|
|
|
|
tipoServicioId: servicio.id,
|
|
|
|
|
tipoNombre: servicio.nombre,
|
|
|
|
|
duracionHoras: servicio.duracionHoras,
|
|
|
|
|
};
|
|
|
|
|
const m = await pasoMostrarHorarios(nuevosDatos, 0);
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, m.resp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "ELEGIR_HORARIO": {
|
|
|
|
|
if (MAS.includes(lower)) {
|
|
|
|
|
const m = await pasoMostrarHorarios(datos, (datos.slotsOffset ?? 0) + SLOTS_POR_PAGINA);
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, m.resp);
|
|
|
|
|
}
|
|
|
|
|
const op = elegirOpcion(datos, texto);
|
|
|
|
|
if (!op)
|
|
|
|
|
return guardar(conv.id, "ELEGIR_HORARIO", datos, [
|
|
|
|
|
{ tipo: "texto", texto: "No entendí. Respondé con la letra del horario (o *más* para ver otros)." },
|
|
|
|
|
]);
|
|
|
|
|
const resumen = `Confirmás este turno?\n\n🔧 Mecánico: ${datos.jefeNombre}\n🛠️ Trabajo: ${datos.tipoNombre}\n📅 ${op.label}\n\nRespondé *sí* o *no*.`;
|
|
|
|
|
return guardar(conv.id, "CONFIRMAR", { ...datos, slotInicio: op.id, slotLabel: op.label }, [
|
|
|
|
|
{ tipo: "texto", texto: resumen },
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "CONFIRMAR": {
|
|
|
|
|
if (NO.includes(lower))
|
|
|
|
|
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: "Listo, no reservé nada. Escribime cuando quieras. 👋" }]);
|
|
|
|
|
if (!SI.includes(lower))
|
|
|
|
|
return guardar(conv.id, "CONFIRMAR", datos, [{ tipo: "texto", texto: "Respondé *sí* para confirmar o *no* para cancelar." }]);
|
|
|
|
|
|
|
|
|
|
const res = await crearTurno({
|
|
|
|
|
clienteId: conv.clienteId!,
|
|
|
|
|
jefeMecanicoId: datos.jefeMecanicoId!,
|
|
|
|
|
tipoServicioId: datos.tipoServicioId!,
|
|
|
|
|
fechaInicio: new Date(datos.slotInicio!),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
if (res.status === 409) {
|
|
|
|
|
const m = await pasoMostrarHorarios(datos, 0);
|
|
|
|
|
return guardar(conv.id, m.paso, m.datos, [
|
|
|
|
|
{ tipo: "texto", texto: "Justo te ganaron ese horario. 😕 Te muestro otros:" },
|
|
|
|
|
...m.resp,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: `No pude reservar: ${res.error}. Probá de nuevo más tarde.` }]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return guardar(conv.id, "INICIO", {}, [
|
|
|
|
|
{
|
|
|
|
|
tipo: "texto",
|
|
|
|
|
texto: `¡Turno confirmado! ✅\n\n🔧 ${datos.jefeNombre}\n🛠️ ${datos.tipoNombre}\n📅 ${datos.slotLabel}\n\nTe esperamos. Si querés otro turno, escribime.`,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: BIENVENIDA }]);
|
|
|
|
|
}
|
|
|
|
|
}
|