From c1b4ad5ad2941fa998f646566d24146c54469b91 Mon Sep 17 00:00:00 2001 From: Beldor Date: Mon, 27 Jul 2026 20:44:33 -0300 Subject: [PATCH] =?UTF-8?q?feat(whatsapp):=20m=C3=A1quina=20de=20conversac?= =?UTF-8?q?i=C3=B3n=20del=20bot=20de=20reservas=20(F2)=20+=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 3 +- scripts/test-whatsapp-bot.ts | 235 ++++++++++++++++++ src/app/api/cliente/turnos/route.ts | 40 +-- src/lib/telefono.ts | 8 + src/lib/turnos.ts | 56 +++++ src/lib/whatsapp-bot.ts | 372 ++++++++++++++++++++++++++++ 6 files changed, 685 insertions(+), 29 deletions(-) create mode 100644 scripts/test-whatsapp-bot.ts create mode 100644 src/lib/telefono.ts create mode 100644 src/lib/turnos.ts create mode 100644 src/lib/whatsapp-bot.ts diff --git a/package.json b/package.json index a537a34..aaf82b3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "db:migrate": "prisma migrate dev", "db:push": "prisma db push", "db:seed": "npx tsx prisma/seed.ts", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "test:bot": "tsx scripts/test-whatsapp-bot.ts" }, "prisma": { "seed": "npx tsx prisma/seed.ts" diff --git a/scripts/test-whatsapp-bot.ts b/scripts/test-whatsapp-bot.ts new file mode 100644 index 0000000..0b7bcfc --- /dev/null +++ b/scripts/test-whatsapp-bot.ts @@ -0,0 +1,235 @@ +/** + * Test de la máquina de estados del bot de WhatsApp SIN base de datos ni Meta. + * + * Inyecta un Prisma en memoria en `globalThis.prisma` ANTES de importar el bot, + * así el singleton de `src/lib/prisma.ts` lo toma y nunca abre una conexión real. + * De paso ejercita el scheduling y crearTurno reales (usan el mismo Prisma). + * + * Correr con: npx tsx scripts/test-whatsapp-bot.ts + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Row = Record; + +let _id = 0; +const nid = (p: string) => `${p}${++_id}`; + +function cmp(x: any) { + return x instanceof Date ? x.getTime() : x; +} +function match(row: Row, where: Row): boolean { + for (const [k, v] of Object.entries(where)) { + if (v === undefined) continue; + if (k === "AND") { + if (!(v as Row[]).every((w) => match(row, w))) return false; + continue; + } + const rv = row[k]; + if (v !== null && typeof v === "object" && !(v instanceof Date)) { + if ("in" in v && !(v.in as any[]).includes(rv)) return false; + if ("lt" in v && !(cmp(rv) < cmp(v.lt))) return false; + if ("lte" in v && !(cmp(rv) <= cmp(v.lte))) return false; + if ("gt" in v && !(cmp(rv) > cmp(v.gt))) return false; + if ("gte" in v && !(cmp(rv) >= cmp(v.gte))) return false; + } else if (rv !== v) { + return false; + } + } + return true; +} +function pick(row: Row, select?: Row) { + if (!select) return { ...row }; + const o: Row = {}; + for (const k of Object.keys(select)) if (select[k]) o[k] = row[k]; + return o; +} + +// ── Stores en memoria ── +const clientes: Row[] = []; +const jefes: Row[] = []; +const servicios: Row[] = []; +const turnos: Row[] = []; +const convs: Row[] = []; + +function modelo(rows: Row[], defaults: () => Row = () => ({})) { + return { + async findFirst({ where }: any = {}) { + const r = rows.find((x) => match(x, where || {})); + return r ? { ...r } : null; + }, + async findUnique({ where, select }: any = {}) { + const r = rows.find((x) => match(x, where || {})); + return r ? pick(r, select) : null; + }, + async findMany({ where, orderBy, select }: any = {}) { + let rs = rows.filter((x) => match(x, where || {})); + if (orderBy) { + const [f, dir] = Object.entries(orderBy)[0] as [string, string]; + rs = [...rs].sort((a, b) => (a[f] > b[f] ? 1 : a[f] < b[f] ? -1 : 0) * (dir === "desc" ? -1 : 1)); + } + return rs.map((r) => pick(r, select)); + }, + async count({ where }: any = {}) { + return rows.filter((x) => match(x, where || {})).length; + }, + async create({ data }: any) { + const row = { ...defaults(), ...data }; + rows.push(row); + return { ...row }; + }, + async update({ where, data }: any) { + const r = rows.find((x) => match(x, where || {})); + if (!r) throw new Error("update: fila no encontrada"); + Object.assign(r, data); + r.updatedAt = new Date(); + return { ...r }; + }, + async upsert({ where, create, update }: any) { + const r = rows.find((x) => match(x, where || {})); + if (r) { + Object.assign(r, update); + return { ...r }; + } + const nuevo = { ...defaults(), ...where, ...create }; + rows.push(nuevo); + return { ...nuevo }; + }, + }; +} + +const fakePrisma: any = { + cliente: modelo(clientes, () => ({ id: nid("c"), telefonoNorm: null, email: null, createdAt: new Date() })), + jefeMecanico: modelo(jefes), + tipoServicio: modelo(servicios), + conversacionWhatsapp: modelo(convs, () => ({ + id: nid("conv"), + paso: "INICIO", + datos: {}, + clienteId: null, + createdAt: new Date(), + updatedAt: new Date(), + })), + turno: { + async count({ where }: any = {}) { + return turnos.filter((x) => match(x, where || {})).length; + }, + async create({ data, include }: any) { + const row: Row = { + id: nid("t"), + estado: "PENDIENTE", + autoId: null, + kilometros: null, + comentario: null, + createdAt: new Date(), + ...data, + }; + turnos.push(row); + const out: Row = { ...row }; + if (include?.auto) out.auto = row.autoId ? { patente: "?", marca: "?", modelo: "?", anio: 0 } : null; + if (include?.tipoServicio) out.tipoServicio = servicios.find((s) => s.id === row.tipoServicioId) ?? null; + if (include?.jefeMecanico) { + const j = jefes.find((x) => x.id === row.jefeMecanicoId); + out.jefeMecanico = j ? { id: j.id, nombre: j.nombre } : null; + } + return out; + }, + }, +}; + +(globalThis as any).prisma = fakePrisma; + +// ── Seed ── +jefes.push({ id: "j1", nombre: "Carlos", email: "j1@t.com", activo: true, slotsParalelos: 1 }); +jefes.push({ id: "j2", nombre: "Diego", email: "j2@t.com", activo: true, slotsParalelos: 1 }); +servicios.push({ id: "s1", nombre: "Cambio de aceite", duracionHoras: 1, activo: true, jefeMecanicoId: "j1" }); +servicios.push({ id: "s2", nombre: "Alineacion", duracionHoras: 2, activo: true, jefeMecanicoId: "j1" }); +clientes.push({ + id: "c1", + nombre: "Juan", + apellido: "Perez", + email: "juan@mail.com", + telefono: "5493510000001", + telefonoNorm: "5493510000001", + createdAt: new Date(), +}); + +// ── Runner de asserts ── +let fallos = 0; +function ok(cond: boolean, msg: string) { + console.log(`${cond ? " ✅" : " ❌"} ${msg}`); + if (!cond) fallos++; +} +const flat = (resp: any[]) => + resp + .map((x) => (x.tipo === "botones" ? `${x.texto} [${x.botones.map((b: any) => b.titulo).join(",")}]` : x.texto)) + .join(" | "); +const hayBotones = (resp: any[]) => resp.some((x) => x.tipo === "botones"); +const paso = (tel: string) => convs.find((c) => c.telefono === tel)?.paso; + +async function main() { + const { procesarMensaje } = await import("../src/lib/whatsapp-bot"); + const env = (tel: string, txt: string) => procesarMensaje(tel, txt); + + console.log("\n── Escenario A: cliente NUEVO, alta + reserva completa ──"); + const A = "5493519999999"; + let r = await env(A, "hola"); + ok(/asistente/i.test(flat(r)) && /nombre/i.test(flat(r)), "bienvenida + pide nombre"); + ok(paso(A) === "ALTA_NOMBRE", "paso=ALTA_NOMBRE"); + + r = await env(A, "Pedro"); + ok(/apellido/i.test(flat(r)), "pide apellido"); + r = await env(A, "Gomez"); + ok(/email/i.test(flat(r)), "pide email"); + r = await env(A, "pedro@mail.com"); + ok(/registrado/i.test(flat(r)) && hayBotones(r), "registrado + botones de mecánico"); + ok(paso(A) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO"); + ok(!!clientes.find((c) => c.telefonoNorm === A), "cliente creado con telefonoNorm"); + + r = await env(A, "j1"); + ok(/Cambio de aceite/i.test(flat(r)), "muestra trabajos del jefe elegido"); + ok(paso(A) === "ELEGIR_TRABAJO", "paso=ELEGIR_TRABAJO"); + + r = await env(A, "a"); + ok(/Horarios/i.test(flat(r)), "muestra horarios"); + ok(paso(A) === "ELEGIR_HORARIO", "paso=ELEGIR_HORARIO"); + + r = await env(A, "a"); + ok(/Confirmás/i.test(flat(r)), "pide confirmación"); + ok(paso(A) === "CONFIRMAR", "paso=CONFIRMAR"); + + r = await env(A, "sí"); + ok(/confirmado/i.test(flat(r)), "turno confirmado"); + ok(paso(A) === "INICIO", "vuelve a INICIO tras reservar"); + const nuevoCliente = clientes.find((c) => c.telefonoNorm === A)!; + const t = turnos.find((x) => x.clienteId === nuevoCliente.id); + ok(!!t, "se creó el turno del cliente"); + ok(t?.autoId === null && t?.kilometros === null, "turno sin auto/km (los carga el jefe)"); + + console.log("\n── Escenario B: cliente EXISTENTE, verificación por email ──"); + const B = "5493510000001"; + r = await env(B, "hola"); + ok(/email/i.test(flat(r)), "pide el email registrado"); + ok(paso(B) === "VERIFICAR_EMAIL", "paso=VERIFICAR_EMAIL"); + r = await env(B, "nope@x.com"); + ok(/no coincide/i.test(flat(r)), "rechaza email incorrecto"); + ok(paso(B) === "VERIFICAR_EMAIL", "sigue en VERIFICAR_EMAIL"); + r = await env(B, "juan@mail.com"); + ok(hayBotones(r), "email correcto → botones de mecánico"); + ok(paso(B) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO"); + + console.log("\n── Escenario C: opción inválida + cancelar ──"); + r = await env(B, "zzz"); + ok(/no entendí/i.test(flat(r)), "opción inválida → 'no entendí'"); + ok(paso(B) === "ELEGIR_MECANICO", "no avanza con opción inválida"); + r = await env(B, "cancelar"); + ok(/cancel/i.test(flat(r)), "cancelar resetea"); + ok(paso(B) === "INICIO", "paso=INICIO tras cancelar"); + + console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`); + process.exit(fallos === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/api/cliente/turnos/route.ts b/src/app/api/cliente/turnos/route.ts index dec5265..126f172 100644 --- a/src/app/api/cliente/turnos/route.ts +++ b/src/app/api/cliente/turnos/route.ts @@ -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 }); } diff --git a/src/lib/telefono.ts b/src/lib/telefono.ts new file mode 100644 index 0000000..227f791 --- /dev/null +++ b/src/lib/telefono.ts @@ -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, ""); +} diff --git a/src/lib/turnos.ts b/src/lib/turnos.ts new file mode 100644 index 0000000..b84270e --- /dev/null +++ b/src/lib/turnos.ts @@ -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> } + | { 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 { + 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 }; +} diff --git a/src/lib/whatsapp-bot.ts b/src/lib/whatsapp-bot.ts new file mode 100644 index 0000000..3c06eee --- /dev/null +++ b/src/lib/whatsapp-bot.ts @@ -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 { + 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 { + 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 }]); + } +}