feat(whatsapp): máquina de conversación del bot de reservas (F2) + test

- lib/turnos.ts: crearTurno() compartido (valida servicio/jefe/slot); cliente/turnos POST lo reutiliza
- lib/telefono.ts: normalización de teléfono (E.164 sin '+') para matchear el wa_id
- lib/whatsapp-bot.ts: procesarMensaje(telefono, texto), state machine completa:
  identidad por teléfono + verificación por email registrado / alta nueva,
  elegir mecánico (botones) -> trabajo (letras) -> horario (letras, con "más") -> confirmar,
  crea turno sin auto/km; expiración 30min, "cancelar" global, manejo de inválidos y 409.
  Devuelve objetos-respuesta (texto/botones) -> independiente de Meta (lo traduce F0).
- scripts/test-whatsapp-bot.ts + npm run test:bot: corre la máquina de estados con un
  Prisma en memoria (sin DB ni Meta), ejercitando scheduling y crearTurno reales. 27/27 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-07-27 20:44:33 -03:00
parent c53e5bb5aa
commit 0046b6c3cf
6 changed files with 685 additions and 29 deletions
+12 -28
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { slotEstaDisponible } from "@/lib/scheduling";
import { crearTurno } from "@/lib/turnos";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
@@ -34,33 +34,17 @@ export async function POST(req: NextRequest) {
});
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } });
if (!jefe || !jefe.activo)
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 });
const inicio = new Date(fechaInicio);
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
const disponible = await slotEstaDisponible(inicio, fin, jefeMecanicoId);
if (!disponible)
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
const turno = await prisma.turno.create({
data: {
clienteId: sesion.sub,
autoId,
tipoServicioId,
jefeMecanicoId,
fechaInicio: inicio,
fechaFin: fin,
kilometros: km,
},
include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
const resultado = await crearTurno({
clienteId: sesion.sub,
jefeMecanicoId,
tipoServicioId,
fechaInicio: new Date(fechaInicio),
autoId,
kilometros: km,
});
return NextResponse.json(turno, { status: 201 });
if (!resultado.ok)
return NextResponse.json({ error: resultado.error }, { status: resultado.status });
return NextResponse.json(resultado.turno, { status: 201 });
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Normaliza un identificador de teléfono a solo dígitos (estilo E.164 sin el '+').
* WhatsApp Cloud API entrega el `wa_id` ya así (ej: "5493511234567"), pero lo pasamos
* por acá para que el matcheo sea consistente venga de donde venga.
*/
export function normalizarTelefono(valor: string): string {
return (valor ?? "").replace(/\D/g, "");
}
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "./prisma";
import { slotEstaDisponible } from "./scheduling";
export interface CrearTurnoInput {
clienteId: string;
jefeMecanicoId: string;
tipoServicioId: string;
fechaInicio: Date;
autoId?: string | null;
kilometros?: number | null;
}
export type CrearTurnoResultado =
| { ok: true; turno: Awaited<ReturnType<typeof prisma.turno.create>> }
| { ok: false; error: string; status: number };
/**
* Crea un turno validando servicio/jefe activos y disponibilidad del slot.
* Lo usan tanto el flujo web (con auto+km) como el bot de WhatsApp (sin auto+km).
* La duración del turno la define el TipoServicio.
*/
export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResultado> {
const { clienteId, jefeMecanicoId, tipoServicioId, fechaInicio } = input;
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return { ok: false, error: "Servicio no disponible", status: 404 };
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } });
if (!jefe || !jefe.activo)
return { ok: false, error: "Jefe mecánico no disponible", status: 404 };
const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId);
if (!disponible) return { ok: false, error: "El horario ya no está disponible", status: 409 };
const turno = await prisma.turno.create({
data: {
clienteId,
jefeMecanicoId,
tipoServicioId,
fechaInicio,
fechaFin: fin,
autoId: input.autoId ?? null,
kilometros: input.kilometros ?? null,
},
include: {
auto: true,
tipoServicio: true,
jefeMecanico: { select: { id: true, nombre: true } },
},
});
return { ok: true, turno };
}
+372
View File
@@ -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 }]);
}
}