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
+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 };
}