From c53e5bb5aa9771c6aa73867154d524c861b7b7d5 Mon Sep 17 00:00:00 2001 From: Beldor Date: Mon, 27 Jul 2026 19:01:54 -0300 Subject: [PATCH 1/6] feat(whatsapp): schema del bot de reservas + panel de trabajos por jefe (F1+F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FASE 1 — schema: - Cliente.email opcional; nuevo telefonoNorm (E.164, único) para matchear por WhatsApp - TipoServicio.jefeMecanicoId opcional (trabajos per-jefe; globales legacy quedan null) - Turno.autoId/kilometros opcionales (reservas por WhatsApp sin auto/km) - Nuevo modelo ConversacionWhatsapp (estado del bot por teléfono) - admin/turnos/[id]: no permite COMPLETADO sin auto+km; Sheets tolera email nulo FASE 3 — panel de trabajos del jefe: - API jefe/servicios (GET/POST) y jefe/servicios/[id] (PATCH/DELETE), scoped al jefe - DELETE desactiva si el trabajo ya tiene turnos asociados - Página /jefe/servicios (alta/listado/activar/editar/eliminar) + nav Turnos|Trabajos - Dashboard del jefe resiliente a turnos sin auto/km ("a completar") Co-Authored-By: Claude Opus 4.8 --- prisma/schema.prisma | 57 +++++--- src/app/api/admin/turnos/[id]/route.ts | 19 ++- src/app/api/jefe/servicios/[id]/route.ts | 66 ++++++++++ src/app/api/jefe/servicios/route.ts | 40 ++++++ src/app/jefe/dashboard/page.tsx | 10 +- src/app/jefe/layout.tsx | 23 ++++ src/app/jefe/servicios/page.tsx | 159 +++++++++++++++++++++++ 7 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 src/app/api/jefe/servicios/[id]/route.ts create mode 100644 src/app/api/jefe/servicios/route.ts create mode 100644 src/app/jefe/servicios/page.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fad811c..e69b735 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,16 +7,18 @@ datasource db { } model Cliente { - id String @id @default(cuid()) - email String @unique - nombre String - apellido String - telefono String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + email String? @unique + nombre String + apellido String + telefono String + telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp + createdAt DateTime @default(now()) - autos Auto[] - turnos Turno[] - magicTokens MagicToken[] + autos Auto[] + turnos Turno[] + magicTokens MagicToken[] + conversaciones ConversacionWhatsapp[] } model Auto { @@ -35,30 +37,32 @@ model Auto { } model TipoServicio { - id String @id @default(cuid()) - nombre String - duracionHoras Float - activo Boolean @default(true) + id String @id @default(cuid()) + nombre String + duracionHoras Float + activo Boolean @default(true) + jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy) - turnos Turno[] - historial HistorialServicio[] + jefeMecanico JefeMecanico? @relation(fields: [jefeMecanicoId], references: [id]) + turnos Turno[] + historial HistorialServicio[] } model Turno { id String @id @default(cuid()) clienteId String - autoId String + autoId String? tipoServicioId String jefeMecanicoId String fechaInicio DateTime fechaFin DateTime - kilometros Int + kilometros Int? estado EstadoTurno @default(PENDIENTE) comentario String? createdAt DateTime @default(now()) cliente Cliente @relation(fields: [clienteId], references: [id]) - auto Auto @relation(fields: [autoId], references: [id]) + auto Auto? @relation(fields: [autoId], references: [id]) tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id]) jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id]) } @@ -72,7 +76,8 @@ model JefeMecanico { activo Boolean @default(true) createdAt DateTime @default(now()) - turnos Turno[] + turnos Turno[] + servicios TipoServicio[] } model HistorialServicio { @@ -117,3 +122,17 @@ enum EstadoTurno { CANCELADO COMPLETADO } + +// Estado de la conversación del bot de WhatsApp, por número de teléfono. +// El webhook es stateless: acá se guarda en qué paso está y las selecciones parciales. +model ConversacionWhatsapp { + id String @id @default(cuid()) + telefono String @unique // wa_id del cliente (E.164, sin '+') + paso String @default("INICIO") // paso de la máquina de estados + datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.) + clienteId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cliente Cliente? @relation(fields: [clienteId], references: [id]) +} diff --git a/src/app/api/admin/turnos/[id]/route.ts b/src/app/api/admin/turnos/[id]/route.ts index 22ff218..3fba57f 100644 --- a/src/app/api/admin/turnos/[id]/route.ts +++ b/src/app/api/admin/turnos/[id]/route.ts @@ -10,13 +10,28 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const { id } = await params; const { estado, comentario } = await req.json(); + // Regla: no se puede cerrar (COMPLETADO) un turno sin auto y kilometraje cargados. + // Las reservas por WhatsApp los dejan vacíos; el jefe mecánico debe completarlos antes de cerrar. + if (estado === "COMPLETADO") { + const actual = await prisma.turno.findUnique({ + where: { id }, + select: { autoId: true, kilometros: true }, + }); + if (!actual) return NextResponse.json({ error: "Turno no encontrado" }, { status: 404 }); + if (!actual.autoId || actual.kilometros == null) + return NextResponse.json( + { error: "Cargá el auto y el kilometraje antes de cerrar el turno" }, + { status: 400 } + ); + } + const turno = await prisma.turno.update({ where: { id }, data: { estado, ...(comentario !== undefined && { comentario }) }, include: { auto: true, tipoServicio: true, cliente: true }, }); - if (estado === "COMPLETADO") { + if (estado === "COMPLETADO" && turno.auto && turno.kilometros != null) { try { await agregarFilaSheet({ patente: turno.auto.patente, @@ -26,7 +41,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id anio: turno.auto.anio, nombre: turno.cliente.nombre, apellido: turno.cliente.apellido, - email: turno.cliente.email, + email: turno.cliente.email ?? "", telefono: turno.cliente.telefono, fecha: turno.fechaInicio, trabajo: turno.tipoServicio.nombre, diff --git a/src/app/api/jefe/servicios/[id]/route.ts b/src/app/api/jefe/servicios/[id]/route.ts new file mode 100644 index 0000000..4c159f4 --- /dev/null +++ b/src/app/api/jefe/servicios/[id]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { verificarSesion } from "@/lib/auth-admin"; + +// Edita un trabajo propio del jefe (nombre, duración, activo). Solo si le pertenece. +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { id } = await params; + const propio = await prisma.tipoServicio.findFirst({ + where: { id, jefeMecanicoId: sesion.sub }, + }); + if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 }); + + const data = await req.json(); + + if (data.duracionHoras !== undefined) { + const dur = Number(data.duracionHoras); + if (!Number.isFinite(dur) || dur <= 0 || dur > 24) + return NextResponse.json( + { error: "La duración debe ser un número de horas entre 0 y 24" }, + { status: 400 } + ); + } + if (data.nombre !== undefined && (typeof data.nombre !== "string" || !data.nombre.trim())) + return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 }); + + const servicio = await prisma.tipoServicio.update({ + where: { id }, + data: { + ...(data.nombre !== undefined && { nombre: data.nombre.trim() }), + ...(data.duracionHoras !== undefined && { duracionHoras: Number(data.duracionHoras) }), + ...(data.activo !== undefined && { activo: Boolean(data.activo) }), + }, + }); + + return NextResponse.json(servicio); +} + +// Elimina un trabajo propio. Si ya tiene turnos asociados, lo desactiva en vez de borrarlo +// (para no romper la integridad referencial de los turnos históricos). +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { id } = await params; + const propio = await prisma.tipoServicio.findFirst({ + where: { id, jefeMecanicoId: sesion.sub }, + }); + if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 }); + + const enUso = await prisma.turno.count({ where: { tipoServicioId: id } }); + if (enUso > 0) { + const servicio = await prisma.tipoServicio.update({ + where: { id }, + data: { activo: false }, + }); + return NextResponse.json({ ...servicio, desactivado: true }); + } + + await prisma.tipoServicio.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/jefe/servicios/route.ts b/src/app/api/jefe/servicios/route.ts new file mode 100644 index 0000000..a4c6617 --- /dev/null +++ b/src/app/api/jefe/servicios/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { verificarSesion } from "@/lib/auth-admin"; + +// Lista los trabajos cargados por el jefe logueado (los suyos, no los globales). +export async function GET() { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const servicios = await prisma.tipoServicio.findMany({ + where: { jefeMecanicoId: sesion.sub }, + orderBy: { nombre: "asc" }, + }); + + return NextResponse.json(servicios); +} + +// Crea un trabajo propio del jefe (nombre + duración en horas). +export async function POST(req: NextRequest) { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { nombre, duracionHoras } = await req.json(); + const dur = Number(duracionHoras); + if (!nombre || typeof nombre !== "string" || !nombre.trim()) + return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 }); + if (!Number.isFinite(dur) || dur <= 0 || dur > 24) + return NextResponse.json( + { error: "La duración debe ser un número de horas entre 0 y 24" }, + { status: 400 } + ); + + const servicio = await prisma.tipoServicio.create({ + data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub }, + }); + + return NextResponse.json(servicio, { status: 201 }); +} diff --git a/src/app/jefe/dashboard/page.tsx b/src/app/jefe/dashboard/page.tsx index dcc1e10..eed9af7 100644 --- a/src/app/jefe/dashboard/page.tsx +++ b/src/app/jefe/dashboard/page.tsx @@ -9,9 +9,9 @@ interface Turno { fechaFin: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO"; comentario: string | null; - kilometros: number; + kilometros: number | null; tipoServicio: { nombre: string }; - auto: { marca: string; modelo: string; patente: string }; + auto: { marca: string; modelo: string; patente: string } | null; cliente: { nombre: string; apellido: string; telefono: string }; } @@ -81,11 +81,11 @@ export default function JefeDashboard() { Teléfono {t.cliente.telefono} Auto - {t.auto.marca} {t.auto.modelo} + {t.auto ? `${t.auto.marca} ${t.auto.modelo}` : a completar} Patente - {t.auto.patente} + {t.auto?.patente ?? "—"} Kilometraje - {t.kilometros.toLocaleString("es-AR")} km + {t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : a completar} {t.comentario && ( diff --git a/src/app/jefe/layout.tsx b/src/app/jefe/layout.tsx index 266b018..c5e3d23 100644 --- a/src/app/jefe/layout.tsx +++ b/src/app/jefe/layout.tsx @@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode }) if (pathname === "/jefe") return <>{children}; + const tabs = [ + { href: "/jefe/dashboard", label: "Turnos" }, + { href: "/jefe/servicios", label: "Trabajos" }, + ]; + return (
@@ -24,6 +29,24 @@ export default function JefeLayout({ children }: { children: React.ReactNode }) Cerrar sesión
+
{children}
diff --git a/src/app/jefe/servicios/page.tsx b/src/app/jefe/servicios/page.tsx new file mode 100644 index 0000000..2ff6cea --- /dev/null +++ b/src/app/jefe/servicios/page.tsx @@ -0,0 +1,159 @@ +"use client"; +import { useEffect, useState } from "react"; +import Card from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; + +interface Servicio { + id: string; + nombre: string; + duracionHoras: number; + activo: boolean; +} + +export default function JefeServicios() { + const [servicios, setServicios] = useState([]); + const [loading, setLoading] = useState(true); + const [nombre, setNombre] = useState(""); + const [duracion, setDuracion] = useState(""); + const [error, setError] = useState(null); + const [guardando, setGuardando] = useState(false); + + useEffect(() => { cargar(); }, []); + + async function cargar() { + setLoading(true); + const res = await fetch("/api/jefe/servicios"); + const data = await res.json(); + setServicios(Array.isArray(data) ? data : []); + setLoading(false); + } + + async function agregar(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setGuardando(true); + const res = await fetch("/api/jefe/servicios", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nombre, duracionHoras: duracion }), + }); + setGuardando(false); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d.error ?? "No se pudo guardar el trabajo"); + return; + } + setNombre(""); + setDuracion(""); + cargar(); + } + + async function toggleActivo(s: Servicio) { + await fetch(`/api/jefe/servicios/${s.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activo: !s.activo }), + }); + cargar(); + } + + async function editarDuracion(s: Servicio) { + const valor = prompt(`Duración de "${s.nombre}" en horas:`, String(s.duracionHoras)); + if (valor == null) return; + const res = await fetch(`/api/jefe/servicios/${s.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ duracionHoras: valor }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error ?? "No se pudo actualizar"); + return; + } + cargar(); + } + + async function eliminar(s: Servicio) { + if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return; + const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" }); + const d = await res.json().catch(() => ({})); + if (d.desactivado) + alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse."); + cargar(); + } + + return ( +
+

Mis trabajos y tiempos

+ + +
+

+ Cargá los trabajos que ofrecés y cuánto tardan. Son los que verá el cliente al reservar por WhatsApp. +

+
+ setNombre(e.target.value)} + required + /> + setDuracion(e.target.value)} + className="sm:w-40" + required + /> +
+ {error &&

{error}

} +
+ +
+
+
+ + {loading ? ( +
Cargando...
+ ) : servicios.length === 0 ? ( + +

Todavía no cargaste ningún trabajo.

+
+ ) : ( +
+ {servicios.map((s) => ( + +
+
+

+ {s.nombre} +

+

+ {s.duracionHoras} {s.duracionHoras === 1 ? "hora" : "horas"} + {!s.activo && " · inactivo"} +

+
+
+ + + +
+
+
+ ))} +
+ )} +
+ ); +} -- 2.52.0 From 0046b6c3cf47f7b0f83df627ad5e5984b9d33206 Mon Sep 17 00:00:00 2001 From: Beldor Date: Mon, 27 Jul 2026 20:44:33 -0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(whatsapp):=20m=C3=A1quina=20de=20conve?= =?UTF-8?q?rsaci=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 }]); + } +} -- 2.52.0 From 15d6e8a2ee8d4209cf0ea9af353f725eed2f6bbb Mon Sep 17 00:00:00 2001 From: jpbeldomenico Date: Tue, 4 Aug 2026 19:00:20 -0300 Subject: [PATCH 3/6] feat(whatsapp): webhook + envio por Graph API (F0) Ultima fase del bot de reservas: conecta la maquina de conversacion ya existente (whatsapp-bot.ts) con la WhatsApp Business Cloud API. - src/lib/whatsapp.ts: traduce RespuestaBot -> payloads de Meta (texto e interactive/button, con los limites de 3 botones / 20 chars / 4096 chars), aplana el payload del webhook y valida X-Hub-Signature-256 en tiempo constante. - src/app/api/whatsapp/webhook/route.ts: GET de verificacion y POST de mensajes. Valida la firma sobre el cuerpo crudo, deduplica por message id (Meta reintenta) y siempre devuelve 200 para no entrar en loop de retries. - scripts/test-whatsapp-envio.ts (npm run test:envio): 30 asserts sobre la capa Meta con fetch stubbeado, sin DB ni credenciales. - docs/whatsapp.md con el alta en el panel de Meta y las 4 env vars. Falta solo cargar las credenciales de Meta en el .env de produccion. Co-Authored-By: Claude Opus 5 --- .env.example | 10 ++ docs/whatsapp.md | 51 +++++++++ package.json | 3 +- scripts/test-whatsapp-envio.ts | 151 ++++++++++++++++++++++++++ src/app/api/whatsapp/webhook/route.ts | 94 ++++++++++++++++ src/lib/whatsapp.ts | 149 +++++++++++++++++++++++++ 6 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 docs/whatsapp.md create mode 100644 scripts/test-whatsapp-envio.ts create mode 100644 src/app/api/whatsapp/webhook/route.ts create mode 100644 src/lib/whatsapp.ts diff --git a/.env.example b/.env.example index 5da1ef6..325c07b 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,13 @@ NEXTAUTH_URL="http://localhost:3000" GMAIL_USER="tu-email@gmail.com" GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" MAGIC_LINK_BASE_URL="http://localhost:3000" + +# Bot de reservas por WhatsApp (Meta Cloud API). +# PHONE_NUMBER_ID: panel de Meta -> WhatsApp -> API Setup (es un id numérico, NO el teléfono). +# TOKEN: token permanente de un System User con whatsapp_business_messaging + _management. +# VERIFY_TOKEN: string inventado por vos; el mismo va acá y en el panel al dar de alta el webhook. +# APP_SECRET: App -> Settings -> Basic -> App Secret. Sin esto el webhook rechaza todo. +WHATSAPP_PHONE_NUMBER_ID="" +WHATSAPP_TOKEN="" +WHATSAPP_VERIFY_TOKEN="" +WHATSAPP_APP_SECRET="" diff --git a/docs/whatsapp.md b/docs/whatsapp.md new file mode 100644 index 0000000..c6cb0fe --- /dev/null +++ b/docs/whatsapp.md @@ -0,0 +1,51 @@ +# Bot de reservas por WhatsApp + +Flujo: el cliente escribe al número del taller → el bot lo identifica por teléfono (verifica +con el email registrado, o lo da de alta) → elige mecánico, trabajo y horario → confirma y se +crea el Turno. Todo pasa dentro de la ventana de 24hs que abre el cliente, así que **no hacen +falta plantillas aprobadas** ni tiene costo. + +## Piezas + +| Archivo | Qué hace | +|---|---| +| `src/lib/whatsapp-bot.ts` | Máquina de conversación. No sabe nada de Meta: devuelve `RespuestaBot[]` | +| `src/lib/whatsapp.ts` | Traduce a/desde la Graph API de Meta y valida la firma del webhook | +| `src/app/api/whatsapp/webhook/route.ts` | Endpoint: verificación (GET) y mensajes entrantes (POST) | + +Tests sin DB ni credenciales: `npm run test:bot` (conversación) y `npm run test:envio` (capa Meta). + +## Variables de entorno + +``` +WHATSAPP_PHONE_NUMBER_ID= # id numérico del número, NO el teléfono +WHATSAPP_TOKEN= # token permanente de System User +WHATSAPP_VERIFY_TOKEN= # string inventado, el mismo acá y en el panel de Meta +WHATSAPP_APP_SECRET= # App -> Settings -> Basic -> App Secret +``` + +Sin `WHATSAPP_APP_SECRET` el webhook rechaza todo con 403 (es a propósito: sin firma no se +puede confiar en el payload). + +## Alta en Meta (una sola vez) + +1. Meta Business Suite con la cuenta de Facebook del taller. +2. developers.facebook.com → crear App tipo **Business** → agregar producto **WhatsApp**. +3. **WhatsApp → API Setup**: de ahí salen `PHONE_NUMBER_ID` y un token temporal (24hs). + Para el token definitivo: Business Settings → System Users → crear usuario → Generate Token + con permisos `whatsapp_business_messaging` y `whatsapp_business_management`. +4. **App → Settings → Basic → App Secret** → `WHATSAPP_APP_SECRET`. +5. Cargar las 4 variables en el `.env` de producción y redeployar. +6. **WhatsApp → Configuration → Webhook**: + - Callback URL: `https://taller.beldo.duckdns.org/api/whatsapp/webhook` + - Verify token: el mismo `WHATSAPP_VERIFY_TOKEN` + - Suscribir el campo **`messages`** (sin esto no llega nada). +7. Probar con el número de prueba que da Meta gratis, antes de migrar el número real. + +Notas: +- El número del taller no puede estar activo en la app de WhatsApp normal/Business: hay que + borrar esa cuenta antes de migrarlo a Cloud API. +- Business Verification se saltea mientras estemos abajo de 1000 usuarios únicos/24hs; solo + hace falta la aprobación del **Display Name**. +- Meta permite 3 botones como máximo. Si hay más de 3 mecánicos activos, salen los 3 primeros + como botón y el resto se elige respondiendo con el número (el bot acepta las dos formas). diff --git a/package.json b/package.json index aaf82b3..6339fb5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "db:push": "prisma db push", "db:seed": "npx tsx prisma/seed.ts", "db:studio": "prisma studio", - "test:bot": "tsx scripts/test-whatsapp-bot.ts" + "test:bot": "tsx scripts/test-whatsapp-bot.ts", + "test:envio": "tsx scripts/test-whatsapp-envio.ts" }, "prisma": { "seed": "npx tsx prisma/seed.ts" diff --git a/scripts/test-whatsapp-envio.ts b/scripts/test-whatsapp-envio.ts new file mode 100644 index 0000000..3ef6ccd --- /dev/null +++ b/scripts/test-whatsapp-envio.ts @@ -0,0 +1,151 @@ +/** + * Test de la capa que habla con Meta (F0) SIN base de datos ni credenciales reales. + * + * Cubre las dos direcciones de `src/lib/whatsapp.ts`: + * - salida: RespuestaBot → payload de la Graph API (con `fetch` stubbeado) + * - entrada: payload del webhook → mensajes/texto + * - firma X-Hub-Signature-256 + * + * Correr con: npx tsx scripts/test-whatsapp-envio.ts + */ + +import crypto from "crypto"; + +process.env.WHATSAPP_TOKEN = "token-de-prueba"; +process.env.WHATSAPP_PHONE_NUMBER_ID = "111222333"; +process.env.WHATSAPP_APP_SECRET = "secreto-de-prueba"; + +import { + enviarRespuestas, + firmaValida, + mensajesDelPayload, + textoDeMensaje, + whatsappConfigurado, +} from "../src/lib/whatsapp"; + +let fallos = 0; +function ok(cond: boolean, msg: string) { + console.log(` ${cond ? "✅" : "❌"} ${msg}`); + if (!cond) fallos++; +} + +// ── Stub de fetch: guarda lo que se le manda a Meta en vez de salir a internet ── +type Llamada = { url: string; auth: string; body: any }; +let llamadas: Llamada[] = []; +globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + llamadas.push({ + url: String(url), + auth: headers["Authorization"] ?? "", + body: JSON.parse(String(init?.body ?? "{}")), + }); + return { ok: true, status: 200, text: async () => "" } as Response; +}) as typeof fetch; + +async function main() { + console.log("\n── Config ──"); + ok(whatsappConfigurado(), "detecta las credenciales presentes"); + + console.log("\n── Salida: texto ──"); + llamadas = []; + await enviarRespuestas("5493511234567", [{ tipo: "texto", texto: "Hola *mundo*" }]); + ok(llamadas.length === 1, "manda un solo request"); + ok(llamadas[0].url.includes("/111222333/messages"), "pega al phone_number_id configurado"); + ok(llamadas[0].auth === "Bearer token-de-prueba", "manda el bearer token"); + ok(llamadas[0].body.messaging_product === "whatsapp", "incluye messaging_product"); + ok(llamadas[0].body.type === "text", "type=text"); + ok(llamadas[0].body.to === "5493511234567", "va al teléfono correcto"); + ok(llamadas[0].body.text.body === "Hola *mundo*", "respeta el markup de WhatsApp"); + + console.log("\n── Salida: botones ──"); + llamadas = []; + await enviarRespuestas("549351", [ + { + tipo: "botones", + texto: "¿Con qué mecánico?", + botones: [ + { id: "j1", titulo: "Carlos" }, + { id: "j2", titulo: "Un nombre larguísimo que no entra" }, + { id: "j3", titulo: "Ana" }, + { id: "j4", titulo: "Cuarto" }, + ], + }, + ]); + const inter = llamadas[0].body.interactive; + ok(llamadas[0].body.type === "interactive", "type=interactive"); + ok(inter.type === "button", "interactive.type=button"); + ok(inter.body.text === "¿Con qué mecánico?", "el texto va en body.text"); + ok(inter.action.buttons.length === 3, "recorta a los 3 botones que permite Meta"); + ok(inter.action.buttons[1].reply.title.length <= 20, "trunca títulos a 20 caracteres"); + ok(inter.action.buttons[0].reply.id === "j1", "conserva el id de la opción"); + ok(inter.action.buttons[0].type === "reply", "los botones son de tipo reply"); + + console.log("\n── Salida: casos borde ──"); + llamadas = []; + await enviarRespuestas("549351", [{ tipo: "botones", texto: "sin opciones", botones: [] }]); + ok(llamadas[0].body.type === "text", "sin botones cae a mensaje de texto"); + + llamadas = []; + await enviarRespuestas("549351", [{ tipo: "texto", texto: "x".repeat(5000) }]); + ok(llamadas[0].body.text.body.length === 4096, "trunca el texto al máximo de Meta"); + + llamadas = []; + await enviarRespuestas("549351", [ + { tipo: "texto", texto: "uno" }, + { tipo: "texto", texto: "dos" }, + ]); + ok(llamadas.length === 2, "manda las respuestas múltiples en orden"); + ok(llamadas[0].body.text.body === "uno" && llamadas[1].body.text.body === "dos", "respeta el orden"); + + console.log("\n── Firma del webhook ──"); + const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" }); + const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex"); + ok(firmaValida(cuerpo, firma), "acepta la firma correcta"); + ok(!firmaValida(cuerpo + " ", firma), "rechaza si el cuerpo fue alterado"); + ok(!firmaValida(cuerpo, "sha256=" + "0".repeat(64)), "rechaza una firma inválida"); + ok(!firmaValida(cuerpo, null), "rechaza si no viene el header"); + ok(!firmaValida(cuerpo, firma.slice("sha256=".length)), "rechaza sin el prefijo sha256="); + + console.log("\n── Entrada: payload del webhook ──"); + const payload = { + entry: [ + { + changes: [ + { + value: { + messages: [ + { from: "549351", id: "wamid.1", type: "text", text: { body: "hola" } }, + { + from: "549351", + id: "wamid.2", + type: "interactive", + interactive: { button_reply: { id: "jefe-abc", title: "Carlos" } }, + }, + { from: "549351", id: "wamid.3", type: "audio", audio: { id: "x" } }, + ], + }, + }, + ], + }, + ], + }; + const msgs = mensajesDelPayload(payload); + ok(msgs.length === 3, "aplana los 3 mensajes del payload"); + ok(textoDeMensaje(msgs[0]) === "hola", "extrae el texto plano"); + ok(textoDeMensaje(msgs[1]) === "jefe-abc", "de un botón extrae el id (lo que espera el bot)"); + ok(textoDeMensaje(msgs[2]) === null, "un audio devuelve null → el bot no lo procesa"); + + ok(mensajesDelPayload({}).length === 0, "payload vacío no rompe"); + ok( + mensajesDelPayload({ entry: [{ changes: [{ value: {} }] }] }).length === 0, + "un evento de status (sin messages) no genera mensajes" + ); + + 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/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts new file mode 100644 index 0000000..230a658 --- /dev/null +++ b/src/app/api/whatsapp/webhook/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { procesarMensaje } from "@/lib/whatsapp-bot"; +import { + enviarRespuestas, + firmaValida, + marcarLeido, + mensajesDelPayload, + textoDeMensaje, + type PayloadWebhook, +} from "@/lib/whatsapp"; + +export const dynamic = "force-dynamic"; + +/** + * Webhook de WhatsApp Business Cloud API. + * GET → verificación del webhook al configurarlo en el panel de Meta. + * POST → mensajes entrantes: se los pasa a la máquina de conversación y se contesta. + */ + +// Meta reintenta el mismo mensaje si tardamos o si algo falla. Guardamos los ids ya +// procesados en memoria (un solo container) para no duplicar turnos ni respuestas. +const procesados = new Set(); +const MAX_PROCESADOS = 500; + +function yaProcesado(id: string): boolean { + if (procesados.has(id)) return true; + procesados.add(id); + // Set mantiene orden de inserción: soltamos los más viejos. + while (procesados.size > MAX_PROCESADOS) { + const viejo: string | undefined = procesados.values().next().value; + if (viejo === undefined) break; + procesados.delete(viejo); + } + return false; +} + +export async function GET(req: NextRequest) { + const params = req.nextUrl.searchParams; + const modo = params.get("hub.mode"); + const token = params.get("hub.verify_token"); + const challenge = params.get("hub.challenge"); + + const esperado = process.env.WHATSAPP_VERIFY_TOKEN; + if (modo === "subscribe" && esperado && token === esperado && challenge) + return new NextResponse(challenge, { status: 200, headers: { "Content-Type": "text/plain" } }); + + return new NextResponse("Forbidden", { status: 403 }); +} + +export async function POST(req: NextRequest) { + // La firma se calcula sobre el cuerpo CRUDO: leerlo como texto y recién ahí parsear. + const cuerpoCrudo = await req.text(); + + if (!firmaValida(cuerpoCrudo, req.headers.get("x-hub-signature-256"))) { + console.warn("[whatsapp] webhook con firma inválida — descartado"); + return new NextResponse("Forbidden", { status: 403 }); + } + + let body: PayloadWebhook; + try { + body = JSON.parse(cuerpoCrudo) as PayloadWebhook; + } catch { + return NextResponse.json({ ok: true }); + } + + try { + for (const mensaje of mensajesDelPayload(body)) { + const telefono = mensaje.from; + const messageId = mensaje.id; + if (!telefono || !messageId || yaProcesado(messageId)) continue; + + void marcarLeido(messageId).catch(() => {}); + + const texto = textoDeMensaje(mensaje); + if (texto === null) { + await enviarRespuestas(telefono, [ + { + tipo: "texto", + texto: "Por ahora solo entiendo mensajes de texto. Escribime qué necesitás. 🙂", + }, + ]); + continue; + } + + const respuestas = await procesarMensaje(telefono, texto); + await enviarRespuestas(telefono, respuestas); + } + } catch (e) { + // Nunca devolvemos error a Meta: reintentaría el mismo mensaje en loop. + console.error("[whatsapp] error procesando webhook:", e); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/lib/whatsapp.ts b/src/lib/whatsapp.ts new file mode 100644 index 0000000..ce8d715 --- /dev/null +++ b/src/lib/whatsapp.ts @@ -0,0 +1,149 @@ +import crypto from "crypto"; +import type { RespuestaBot } from "./whatsapp-bot"; + +/** + * Cliente de la WhatsApp Business Cloud API (Graph API de Meta). + * Traduce las `RespuestaBot` que emite la máquina de conversación a los payloads + * que espera Meta. El bot no sabe nada de Meta: todo el acople vive acá. + */ + +const GRAPH_VERSION = "v21.0"; + +// Límites de la API de Meta — si nos pasamos, el mensaje se rechaza entero. +const MAX_TEXTO = 4096; +const MAX_BODY_INTERACTIVO = 1024; +const MAX_BOTONES = 3; +const MAX_TITULO_BOTON = 20; + +function credenciales() { + const token = process.env.WHATSAPP_TOKEN; + const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; + if (!token || !phoneNumberId) return null; + return { token, phoneNumberId }; +} + +export function whatsappConfigurado(): boolean { + return credenciales() !== null; +} + +/** Valida la firma X-Hub-Signature-256 del webhook contra el cuerpo crudo. */ +export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): boolean { + const appSecret = process.env.WHATSAPP_APP_SECRET; + if (!appSecret) return false; + if (!firmaHeader?.startsWith("sha256=")) return false; + + const esperada = crypto.createHmac("sha256", appSecret).update(cuerpoCrudo, "utf8").digest("hex"); + const recibida = firmaHeader.slice("sha256=".length); + + // Comparación de tiempo constante; timingSafeEqual explota si difieren los largos. + const a = Buffer.from(esperada, "utf8"); + const b = Buffer.from(recibida, "utf8"); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +async function postMensaje(payload: Record): Promise { + const cred = credenciales(); + if (!cred) { + console.error("[whatsapp] falta WHATSAPP_TOKEN o WHATSAPP_PHONE_NUMBER_ID; no se envía nada"); + return; + } + + const res = await fetch(`https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`, { + method: "POST", + headers: { + Authorization: `Bearer ${cred.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ messaging_product: "whatsapp", ...payload }), + }); + + if (!res.ok) { + const detalle = await res.text().catch(() => ""); + console.error(`[whatsapp] error ${res.status} al enviar mensaje: ${detalle}`); + } +} + +async function enviarTexto(telefono: string, texto: string): Promise { + await postMensaje({ + to: telefono, + type: "text", + text: { body: texto.slice(0, MAX_TEXTO), preview_url: false }, + }); +} + +async function enviarBotones( + telefono: string, + texto: string, + botones: { id: string; titulo: string }[] +): Promise { + // Meta banca 3 botones como máximo. La máquina de conversación siempre deja las + // mismas opciones disponibles por número/letra, así que recortar no rompe el flujo. + const recortados = botones.slice(0, MAX_BOTONES).map((b) => ({ + type: "reply", + reply: { id: b.id, title: b.titulo.slice(0, MAX_TITULO_BOTON) }, + })); + + if (recortados.length === 0) { + await enviarTexto(telefono, texto); + return; + } + + await postMensaje({ + to: telefono, + type: "interactive", + interactive: { + type: "button", + body: { text: texto.slice(0, MAX_BODY_INTERACTIVO) }, + action: { buttons: recortados }, + }, + }); +} + +/** Envía en orden las respuestas del bot (secuencial: WhatsApp respeta el orden de llegada). */ +export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise { + for (const r of respuestas) { + if (r.tipo === "botones") await enviarBotones(telefono, r.texto, r.botones); + else await enviarTexto(telefono, r.texto); + } +} + +/** Marca el mensaje como leído (los tildes azules). Best-effort: si falla, no importa. */ +export async function marcarLeido(messageId: string): Promise { + await postMensaje({ status: "read", message_id: messageId }); +} + +// ── Entrada: payload del webhook → mensajes ── + +// Forma (parcial) de lo que manda Meta; solo lo que consumimos. +export type MensajeMeta = { + from?: string; + id?: string; + type?: string; + text?: { body?: string }; + button?: { text?: string }; + interactive?: { button_reply?: { id?: string }; list_reply?: { id?: string } }; +}; +export type PayloadWebhook = { + entry?: { changes?: { value?: { messages?: MensajeMeta[] } }[] }[]; +}; + +/** Aplana el payload del webhook a la lista de mensajes entrantes (ignora `statuses`). */ +export function mensajesDelPayload(body: PayloadWebhook): MensajeMeta[] { + const salida: MensajeMeta[] = []; + for (const entry of body?.entry ?? []) + for (const change of entry?.changes ?? []) salida.push(...(change?.value?.messages ?? [])); + return salida; +} + +/** Saca el texto de un mensaje según su tipo. `null` = tipo que el bot no maneja. */ +export function textoDeMensaje(mensaje: MensajeMeta): string | null { + if (mensaje.type === "text") return mensaje.text?.body ?? ""; + if (mensaje.type === "interactive") { + const i = mensaje.interactive ?? {}; + // El id del botón/opción es el que puso el bot en las opciones → matchea directo. + return i.button_reply?.id ?? i.list_reply?.id ?? null; + } + if (mensaje.type === "button") return mensaje.button?.text ?? null; + return null; +} -- 2.52.0 From 44aeb58e774d3fb8d50d70f474db3a292defba57 Mon Sep 17 00:00:00 2001 From: Beldor Date: Wed, 5 Aug 2026 09:45:21 -0300 Subject: [PATCH 4/6] feat(cliente): invertir el flujo web a jefe -> trabajo Los trabajos ahora son por jefe mecanico, asi que el cliente elige primero el mecanico y despues el trabajo, igual que en el bot de WhatsApp. Antes listaba todos los servicios del taller y recien despues pedia el jefe. - src/lib/servicios.ts: regla unica de que trabajos ofrece un jefe = los suyos + los globales del taller (jefeMecanicoId null, los carga el admin). Asi no se rompe lo que ya existe en produccion, donde todos los servicios son globales. - /api/cliente/servicios ahora exige jefeMecanicoId y aplica esa regla. - /api/cliente/slots y crearTurno rechazan un trabajo propio de OTRO jefe (400): antes se podia reservar con el jefe A la duracion del trabajo del B. - whatsapp-bot: usa la misma regla, asi la web y el bot ofrecen lo mismo. - cliente/turnos: pasos invertidos, cabeceras y back acordes, y estado vacio propio cuando el jefe no tiene trabajos. - test-whatsapp-bot: soporte de OR en el Prisma en memoria, trabajo global en el seed y escenario D (globales visibles, trabajo ajeno rechazado). 31 asserts. Co-Authored-By: Claude Opus 5 --- scripts/test-whatsapp-bot.ts | 27 ++++++ src/app/api/cliente/servicios/route.ts | 13 ++- src/app/api/cliente/slots/route.ts | 3 + src/app/cliente/turnos/page.tsx | 119 ++++++++++++++----------- src/lib/servicios.ts | 24 +++++ src/lib/turnos.ts | 5 ++ src/lib/whatsapp-bot.ts | 5 +- 7 files changed, 141 insertions(+), 55 deletions(-) create mode 100644 src/lib/servicios.ts diff --git a/scripts/test-whatsapp-bot.ts b/scripts/test-whatsapp-bot.ts index 0b7bcfc..ff56c31 100644 --- a/scripts/test-whatsapp-bot.ts +++ b/scripts/test-whatsapp-bot.ts @@ -24,6 +24,10 @@ function match(row: Row, where: Row): boolean { if (!(v as Row[]).every((w) => match(row, w))) return false; continue; } + if (k === "OR") { + if (!(v as Row[]).some((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; @@ -143,6 +147,8 @@ jefes.push({ id: "j1", nombre: "Carlos", email: "j1@t.com", activo: true, slotsP 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" }); +// Trabajo global del taller (lo carga el admin): lo ofrece cualquier jefe. +servicios.push({ id: "s3", nombre: "Revision general", duracionHoras: 1, activo: true, jefeMecanicoId: null }); clientes.push({ id: "c1", nombre: "Juan", @@ -187,6 +193,7 @@ async function main() { r = await env(A, "j1"); ok(/Cambio de aceite/i.test(flat(r)), "muestra trabajos del jefe elegido"); + ok(/Revision general/i.test(flat(r)), "incluye los trabajos globales del taller"); ok(paso(A) === "ELEGIR_TRABAJO", "paso=ELEGIR_TRABAJO"); r = await env(A, "a"); @@ -225,6 +232,26 @@ async function main() { ok(/cancel/i.test(flat(r)), "cancelar resetea"); ok(paso(B) === "INICIO", "paso=INICIO tras cancelar"); + console.log("\n── Escenario D: trabajos por jefe ──"); + const D = "5493518888888"; + await env(D, "hola"); + await env(D, "Ana"); + await env(D, "Lopez"); + r = await env(D, "ana@mail.com"); + ok(hayBotones(r), "alta rápida hasta elegir mecánico"); + r = await env(D, "j2"); + ok(/Revision general/i.test(flat(r)), "un jefe sin trabajos propios igual ofrece los globales"); + ok(!/Cambio de aceite/i.test(flat(r)), "NO ofrece los trabajos propios de otro jefe"); + + const { crearTurno } = await import("../src/lib/turnos"); + const cruzado = await crearTurno({ + clienteId: "c1", + jefeMecanicoId: "j2", + tipoServicioId: "s1", // trabajo propio de j1 + fechaInicio: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + ok(!cruzado.ok && cruzado.status === 400, "crearTurno rechaza el trabajo de otro jefe (400)"); + console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`); process.exit(fallos === 0 ? 0 : 1); } diff --git a/src/app/api/cliente/servicios/route.ts b/src/app/api/cliente/servicios/route.ts index 0f13d0d..ffc6b00 100644 --- a/src/app/api/cliente/servicios/route.ts +++ b/src/app/api/cliente/servicios/route.ts @@ -1,13 +1,20 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { verificarSesion } from "@/lib/auth-admin"; +import { whereTrabajosDeJefe } from "@/lib/servicios"; -export async function GET() { +// Trabajos que ofrece un jefe mecánico: los suyos + los globales del taller. +// El flujo del cliente es jefe → trabajo, así que el jefe es obligatorio. +export async function GET(req: NextRequest) { const sesion = await verificarSesion("cliente_session"); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + const jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId"); + if (!jefeMecanicoId) + return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 }); + const servicios = await prisma.tipoServicio.findMany({ - where: { activo: true }, + where: whereTrabajosDeJefe(jefeMecanicoId), orderBy: { nombre: "asc" }, }); diff --git a/src/app/api/cliente/slots/route.ts b/src/app/api/cliente/slots/route.ts index 3540c1a..5f06b61 100644 --- a/src/app/api/cliente/slots/route.ts +++ b/src/app/api/cliente/slots/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { verificarSesion } from "@/lib/auth-admin"; import { obtenerSlotsDisponibles } from "@/lib/scheduling"; +import { trabajoEsDeJefe } from "@/lib/servicios"; export async function GET(req: NextRequest) { const sesion = await verificarSesion("cliente_session"); @@ -18,6 +19,8 @@ export async function GET(req: NextRequest) { const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } }); if (!servicio || !servicio.activo) return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 }); + if (!trabajoEsDeJefe(servicio, jefeMecanicoId)) + return NextResponse.json({ error: "Ese trabajo no lo hace este mecánico" }, { status: 400 }); const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500); return NextResponse.json(slots); diff --git a/src/app/cliente/turnos/page.tsx b/src/app/cliente/turnos/page.tsx index fb30a48..2eb2d1d 100644 --- a/src/app/cliente/turnos/page.tsx +++ b/src/app/cliente/turnos/page.tsx @@ -20,7 +20,9 @@ interface Turno { const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]; -type Step = "servicio" | "jefe" | "calendario" | "confirmado"; +// El cliente elige primero el mecánico y después el trabajo, porque cada jefe +// carga sus propios trabajos (además de los globales del taller). +type Step = "jefe" | "servicio" | "calendario" | "confirmado"; function formatDuracion(h: number) { if (h < 1) return `${Math.round(h * 60)} min`; @@ -66,12 +68,13 @@ function TurnosContent() { const params = useSearchParams(); const autoId = params.get("autoId"); - const [step, setStep] = useState("servicio"); + const [step, setStep] = useState("jefe"); const [servicios, setServicios] = useState([]); const [servicioSel, setServicioSel] = useState(null); + const [loadingServicios, setLoadingServicios] = useState(false); const [jefes, setJefes] = useState([]); const [jefeSel, setJefeSel] = useState(null); - const [loadingJefes, setLoadingJefes] = useState(false); + const [loadingJefes, setLoadingJefes] = useState(true); const [slots, setSlots] = useState([]); const [loadingSlots, setLoadingSlots] = useState(false); const [misTurnos, setMisTurnos] = useState([]); @@ -90,7 +93,12 @@ function TurnosContent() { useEffect(() => { if (!autoId) { router.push("/cliente/dashboard"); return; } - fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios); + fetch("/api/cliente/jefes") + .then(r => r.json()) + .then((data) => { + setJefes(Array.isArray(data) ? data : []); + setLoadingJefes(false); + }); fetch("/api/cliente/turnos") .then(r => r.json()) .then((data) => { @@ -110,29 +118,30 @@ function TurnosContent() { return m; }, [slots]); - async function seleccionarServicio(s: TipoServicio) { - setServicioSel(s); - setJefeSel(null); + async function seleccionarJefe(j: Jefe) { + setJefeSel(j); + setServicioSel(null); + setServicios([]); setSlots([]); setDiaSeleccionado(null); setSlotSel(null); - setLoadingJefes(true); - setStep("jefe"); - const res = await fetch("/api/cliente/jefes"); + setLoadingServicios(true); + setStep("servicio"); + const res = await fetch(`/api/cliente/servicios?jefeMecanicoId=${j.id}`); const data = await res.json(); - setJefes(Array.isArray(data) ? data : []); - setLoadingJefes(false); + setServicios(Array.isArray(data) ? data : []); + setLoadingServicios(false); } - async function seleccionarJefe(j: Jefe) { - if (!servicioSel) return; - setJefeSel(j); + async function seleccionarServicio(s: TipoServicio) { + if (!jefeSel) return; + setServicioSel(s); setLoadingSlots(true); setSlots([]); setDiaSeleccionado(null); setSlotSel(null); setStep("calendario"); - const res = await fetch(`/api/cliente/slots?tipoServicioId=${servicioSel.id}&jefeMecanicoId=${j.id}`); + const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}&jefeMecanicoId=${jefeSel.id}`); const data = await res.json(); setSlots(Array.isArray(data) ? data : []); setLoadingSlots(false); @@ -213,14 +222,14 @@ function TurnosContent() {
✅

¡Turno reservado!

-

Servicio

-

{turnoCreado.tipoServicio.nombre}

{turnoCreado.jefeMecanico && ( <>

Jefe mecánico

{turnoCreado.jefeMecanico.nombre}

)} +

Servicio

+

{turnoCreado.tipoServicio.nombre}

Fecha

{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", { timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long" @@ -236,33 +245,46 @@ function TurnosContent() {

{step === "calendario" ? ( - - ) : step === "jefe" ? ( + ) : step === "servicio" ? ( + ) : ( )}

- {step === "servicio" && "Elegí el servicio"} {step === "jefe" && "Elegí el jefe mecánico"} - {step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`} + {step === "servicio" && `Trabajos de ${jefeSel?.nombre}`} + {step === "calendario" && `${jefeSel?.nombre}${servicioSel ? ` · ${servicioSel.nombre}` : ""}`}

- {/* STEP: servicio */} - {step === "servicio" && ( + {/* STEP: jefe */} + {step === "jefe" && ( <> -
- {servicios.map(s => ( - - ))} +
+ {loadingJefes ? ( +

Cargando jefes...

+ ) : jefes.length === 0 ? ( + +

+ No hay jefes mecánicos disponibles en este momento. Intentá más tarde. +

+
+ ) : ( + jefes.map((j) => ( + + )) + )}
{!loadingTurnos && misTurnos.length > 0 && ( @@ -288,27 +310,24 @@ function TurnosContent() { )} - {/* STEP: jefe */} - {step === "jefe" && ( -
- {loadingJefes ? ( -

Cargando jefes...

- ) : jefes.length === 0 ? ( + {/* STEP: servicio */} + {step === "servicio" && ( +
+ {loadingServicios ? ( +

Cargando trabajos...

+ ) : servicios.length === 0 ? (

- No hay jefes mecánicos disponibles en este momento. Intentá más tarde. + {jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico.

) : ( - jefes.map((j) => ( - diff --git a/src/lib/servicios.ts b/src/lib/servicios.ts new file mode 100644 index 0000000..fcfd8a2 --- /dev/null +++ b/src/lib/servicios.ts @@ -0,0 +1,24 @@ +import type { Prisma, TipoServicio } from "@prisma/client"; + +/** + * Regla única de qué trabajos ofrece un jefe mecánico. + * + * Los jefes cargan sus propios trabajos desde `/jefe/servicios` (`jefeMecanicoId` = su id), + * y el admin puede cargar trabajos globales del taller (`jefeMecanicoId` = null) que ofrece + * cualquiera. El cliente elige primero el mecánico y después el trabajo, así que la lista + * siempre es: los del jefe + los globales. + */ +export function whereTrabajosDeJefe(jefeMecanicoId: string): Prisma.TipoServicioWhereInput { + return { + activo: true, + OR: [{ jefeMecanicoId }, { jefeMecanicoId: null }], + }; +} + +/** Un trabajo propio de otro jefe no se puede reservar con este. Los globales sí. */ +export function trabajoEsDeJefe( + servicio: Pick, + jefeMecanicoId: string +): boolean { + return servicio.jefeMecanicoId === null || servicio.jefeMecanicoId === jefeMecanicoId; +} diff --git a/src/lib/turnos.ts b/src/lib/turnos.ts index b84270e..2a9a13c 100644 --- a/src/lib/turnos.ts +++ b/src/lib/turnos.ts @@ -1,5 +1,6 @@ import { prisma } from "./prisma"; import { slotEstaDisponible } from "./scheduling"; +import { trabajoEsDeJefe } from "./servicios"; export interface CrearTurnoInput { clienteId: string; @@ -30,6 +31,10 @@ export async function crearTurno(input: CrearTurnoInput): Promise { const servicios = await prisma.tipoServicio.findMany({ - where: { jefeMecanicoId: datos.jefeMecanicoId, activo: true }, + where: whereTrabajosDeJefe(datos.jefeMecanicoId!), orderBy: { nombre: "asc" }, }); @@ -303,7 +304,7 @@ export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Pr 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) { + if (!servicio || !servicio.activo || !trabajoEsDeJefe(servicio, datos.jefeMecanicoId!)) { const m = await pasoElegirTrabajo(datos); return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "Ese trabajo ya no está disponible." }, ...m.resp]); } -- 2.52.0 From 91112862a544bff48a4fa7ef37fb3ca39898dba8 Mon Sep 17 00:00:00 2001 From: Beldor Date: Wed, 5 Aug 2026 11:21:35 -0300 Subject: [PATCH 5/6] fix: los cuatro hallazgos reales de la revision con IA - crearTurno verificaba disponibilidad y creaba el turno en dos operaciones sueltas: dos clientes podian pasar la verificacion a la vez y sobrevender el mismo horario. Ahora va todo en una transaccion, serializada por jefe con pg_advisory_xact_lock. slotEstaDisponible acepta el cliente de la transaccion. Probado contra un Postgres real: 5 reservas simultaneas del mismo slot con capacidad 1 dan 1 ok + 4 conflictos y una sola fila; sacando el lock, entran las 5. - La deduplicacion del webhook estaba en memoria y solo servia con una instancia. Pasa a la base con el modelo MensajeWhatsappProcesado: el insert del id es atomico por el indice unico, asi que aguanta reintentos concurrentes de Meta (verificado). Se limpian los ids de mas de 7 dias. - postMensaje no reintentaba: ahora reintenta 3 veces ante 429, 5xx y fallas de red, con backoff exponencial y jitter. Un 4xx nuestro no se reintenta. - /api/cliente/servicios devolvia los trabajos de un jefe dado de baja; ahora responde 404 si el jefe no existe o no esta activo. test:envio suma los casos de reintento (35 asserts). El Prisma en memoria del test del bot ahora entiende $transaction y $executeRaw. Co-Authored-By: Claude Opus 5 --- prisma/schema.prisma | 10 ++++++ scripts/test-whatsapp-bot.ts | 8 +++++ scripts/test-whatsapp-envio.ts | 32 +++++++++++++++++- src/app/api/cliente/servicios/route.ts | 8 +++++ src/app/api/whatsapp/webhook/route.ts | 45 ++++++++++++++++++-------- src/lib/scheduling.ts | 24 ++++++++++---- src/lib/turnos.ts | 43 ++++++++++++++---------- src/lib/whatsapp.ts | 42 +++++++++++++++++++++--- 8 files changed, 168 insertions(+), 44 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e69b735..dec7cd4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,3 +136,13 @@ model ConversacionWhatsapp { cliente Cliente? @relation(fields: [clienteId], references: [id]) } + +// Ids de mensajes de WhatsApp ya procesados. Meta reintenta el mismo mensaje si el +// webhook tarda o falla, y sin esto un reintento crea el turno dos veces. Va en la +// base (y no en memoria) para que siga valiendo con más de una instancia. +model MensajeWhatsappProcesado { + id String @id // wamid que manda Meta + createdAt DateTime @default(now()) + + @@index([createdAt]) +} diff --git a/scripts/test-whatsapp-bot.ts b/scripts/test-whatsapp-bot.ts index ff56c31..4b8b44e 100644 --- a/scripts/test-whatsapp-bot.ts +++ b/scripts/test-whatsapp-bot.ts @@ -138,6 +138,14 @@ const fakePrisma: any = { return out; }, }, + // `crearTurno` verifica y crea dentro de una transacción con un lock por jefe. + // Acá corre todo sobre el mismo cliente falso, que ya es secuencial. + async $transaction(fn: any) { + return fn(fakePrisma); + }, + async $executeRaw() { + return 0; + }, }; (globalThis as any).prisma = fakePrisma; diff --git a/scripts/test-whatsapp-envio.ts b/scripts/test-whatsapp-envio.ts index 3ef6ccd..992f153 100644 --- a/scripts/test-whatsapp-envio.ts +++ b/scripts/test-whatsapp-envio.ts @@ -32,6 +32,8 @@ function ok(cond: boolean, msg: string) { // ── Stub de fetch: guarda lo que se le manda a Meta en vez de salir a internet ── type Llamada = { url: string; auth: string; body: any }; let llamadas: Llamada[] = []; +// Códigos a devolver, en orden. Si se vacía, responde 200. +let respuestas: number[] = []; globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { const headers = (init?.headers ?? {}) as Record; llamadas.push({ @@ -39,7 +41,8 @@ globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { auth: headers["Authorization"] ?? "", body: JSON.parse(String(init?.body ?? "{}")), }); - return { ok: true, status: 200, text: async () => "" } as Response; + const status = respuestas.shift() ?? 200; + return { ok: status < 400, status, text: async () => "detalle" } as Response; }) as typeof fetch; async function main() { @@ -97,6 +100,33 @@ async function main() { ok(llamadas.length === 2, "manda las respuestas múltiples en orden"); ok(llamadas[0].body.text.body === "uno" && llamadas[1].body.text.body === "dos", "respeta el orden"); + console.log("\n── Salida: reintentos ──"); + llamadas = []; + respuestas = [429]; + await enviarRespuestas("549351", [{ tipo: "texto", texto: "con rate limit" }]); + ok(llamadas.length === 2, "reintenta ante un 429 y sale bien en el segundo intento"); + + llamadas = []; + respuestas = [500, 503]; + await enviarRespuestas("549351", [{ tipo: "texto", texto: "con 5xx" }]); + ok(llamadas.length === 3, "reintenta ante 5xx hasta que uno sale bien"); + + llamadas = []; + respuestas = [500, 500, 500]; + await enviarRespuestas("549351", [{ tipo: "texto", texto: "siempre 5xx" }]); + ok(llamadas.length === 3, "no reintenta más de 3 veces"); + + llamadas = []; + respuestas = [400]; + await enviarRespuestas("549351", [{ tipo: "texto", texto: "payload malo" }]); + ok(llamadas.length === 1, "un 400 no se reintenta (es error nuestro)"); + + llamadas = []; + respuestas = [401]; + await enviarRespuestas("549351", [{ tipo: "texto", texto: "token vencido" }]); + ok(llamadas.length === 1, "un 401 no se reintenta"); + respuestas = []; + console.log("\n── Firma del webhook ──"); const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" }); const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex"); diff --git a/src/app/api/cliente/servicios/route.ts b/src/app/api/cliente/servicios/route.ts index ffc6b00..9a1dc49 100644 --- a/src/app/api/cliente/servicios/route.ts +++ b/src/app/api/cliente/servicios/route.ts @@ -13,6 +13,14 @@ export async function GET(req: NextRequest) { if (!jefeMecanicoId) return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 }); + // Un jefe dado de baja no debe exponer sus trabajos (ni dejar arrancar una reserva). + const jefe = await prisma.jefeMecanico.findUnique({ + where: { id: jefeMecanicoId }, + select: { activo: true }, + }); + if (!jefe || !jefe.activo) + return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 }); + const servicios = await prisma.tipoServicio.findMany({ where: whereTrabajosDeJefe(jefeMecanicoId), orderBy: { nombre: "asc" }, diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 230a658..0aa77ee 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -1,4 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; import { procesarMensaje } from "@/lib/whatsapp-bot"; import { enviarRespuestas, @@ -17,21 +19,34 @@ export const dynamic = "force-dynamic"; * POST → mensajes entrantes: se los pasa a la máquina de conversación y se contesta. */ -// Meta reintenta el mismo mensaje si tardamos o si algo falla. Guardamos los ids ya -// procesados en memoria (un solo container) para no duplicar turnos ni respuestas. -const procesados = new Set(); -const MAX_PROCESADOS = 500; +// Meta reintenta el mismo mensaje si tardamos o si algo falla, y sin deduplicar eso +// crea el turno dos veces. Se resuelve insertando el id en la base: el índice único +// hace la operación atómica, así que sirve incluso con varias instancias corriendo. +const DIAS_RETENCION = 7; +let ultimaLimpieza = 0; -function yaProcesado(id: string): boolean { - if (procesados.has(id)) return true; - procesados.add(id); - // Set mantiene orden de inserción: soltamos los más viejos. - while (procesados.size > MAX_PROCESADOS) { - const viejo: string | undefined = procesados.values().next().value; - if (viejo === undefined) break; - procesados.delete(viejo); +async function yaProcesado(id: string): Promise { + try { + await prisma.mensajeWhatsappProcesado.create({ data: { id } }); + return false; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") return true; + throw e; + } +} + +/** Borra los ids viejos como mucho una vez por día. Best-effort: si falla, no importa. */ +async function limpiarProcesadosViejos(): Promise { + const unDia = 24 * 60 * 60 * 1000; + if (Date.now() - ultimaLimpieza < unDia) return; + ultimaLimpieza = Date.now(); + try { + await prisma.mensajeWhatsappProcesado.deleteMany({ + where: { createdAt: { lt: new Date(Date.now() - DIAS_RETENCION * unDia) } }, + }); + } catch (e) { + console.error("[whatsapp] no se pudieron limpiar los mensajes procesados:", e); } - return false; } export async function GET(req: NextRequest) { @@ -64,10 +79,12 @@ export async function POST(req: NextRequest) { } try { + await limpiarProcesadosViejos(); + for (const mensaje of mensajesDelPayload(body)) { const telefono = mensaje.from; const messageId = mensaje.id; - if (!telefono || !messageId || yaProcesado(messageId)) continue; + if (!telefono || !messageId || (await yaProcesado(messageId))) continue; void marcarLeido(messageId).catch(() => {}); diff --git a/src/lib/scheduling.ts b/src/lib/scheduling.ts index 4a05f0f..e99d841 100644 --- a/src/lib/scheduling.ts +++ b/src/lib/scheduling.ts @@ -1,5 +1,13 @@ +import type { Prisma } from "@prisma/client"; import { prisma } from "./prisma"; +/** + * Cliente de Prisma o el cliente de una transacción en curso. Permite que la + * verificación de disponibilidad corra dentro de la misma transacción que crea + * el turno (ver `crearTurno`), que es lo que evita reservas superpuestas. + */ +type ClienteDb = Prisma.TransactionClient; + const INICIO_H = 8; const INICIO_M = 30; const FIN_H = 17; @@ -53,9 +61,10 @@ function horasDentroDeJornada(inicio: Date, fin: Date): boolean { async function turnosSolapados( inicio: Date, fin: Date, - jefeMecanicoId: string + jefeMecanicoId: string, + db: ClienteDb = prisma ): Promise { - const count = await prisma.turno.count({ + const count = await db.turno.count({ where: { jefeMecanicoId, estado: { in: ["PENDIENTE", "CONFIRMADO"] }, @@ -65,8 +74,8 @@ async function turnosSolapados( return count; } -async function obtenerJefeActivo(jefeMecanicoId: string) { - return prisma.jefeMecanico.findUnique({ +async function obtenerJefeActivo(jefeMecanicoId: string, db: ClienteDb = prisma) { + return db.jefeMecanico.findUnique({ where: { id: jefeMecanicoId }, select: { slotsParalelos: true, activo: true }, }); @@ -133,11 +142,12 @@ export async function obtenerSlotsDisponibles( export async function slotEstaDisponible( inicio: Date, fin: Date, - jefeMecanicoId: string + jefeMecanicoId: string, + db: ClienteDb = prisma ): Promise { if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false; - const jefe = await obtenerJefeActivo(jefeMecanicoId); + const jefe = await obtenerJefeActivo(jefeMecanicoId, db); if (!jefe || !jefe.activo) return false; - const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId); + const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId, db); return ocupados < jefe.slotsParalelos; } diff --git a/src/lib/turnos.ts b/src/lib/turnos.ts index 2a9a13c..0d6edd6 100644 --- a/src/lib/turnos.ts +++ b/src/lib/turnos.ts @@ -37,25 +37,34 @@ export async function crearTurno(input: CrearTurnoInput): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${jefeMecanicoId}))`; - 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 } }, - }, + const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId, tx); + if (!disponible) return null; + + return tx.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 } }, + }, + }); }); + if (!turno) return { ok: false, error: "El horario ya no está disponible", status: 409 }; + return { ok: true, turno }; } diff --git a/src/lib/whatsapp.ts b/src/lib/whatsapp.ts index ce8d715..4f29a5b 100644 --- a/src/lib/whatsapp.ts +++ b/src/lib/whatsapp.ts @@ -42,6 +42,16 @@ export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): bo return crypto.timingSafeEqual(a, b); } +// Meta tira 429 (rate limit) y 5xx transitorios; ahí conviene reintentar en vez de +// perder la respuesta al cliente. Un 4xx que no sea 429 es error nuestro: no se +// reintenta porque va a fallar igual. +const INTENTOS = 3; +const ESPERA_BASE_MS = 500; + +function esperar(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + async function postMensaje(payload: Record): Promise { const cred = credenciales(); if (!cred) { @@ -49,18 +59,40 @@ async function postMensaje(payload: Record): Promise { return; } - const res = await fetch(`https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`, { + const url = `https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`; + const init: RequestInit = { method: "POST", headers: { Authorization: `Bearer ${cred.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ messaging_product: "whatsapp", ...payload }), - }); + }; - if (!res.ok) { - const detalle = await res.text().catch(() => ""); - console.error(`[whatsapp] error ${res.status} al enviar mensaje: ${detalle}`); + for (let intento = 1; intento <= INTENTOS; intento++) { + let status: number; + let detalle = ""; + try { + const res = await fetch(url, init); + if (res.ok) return; + status = res.status; + detalle = await res.text().catch(() => ""); + } catch (e) { + // Falla de red: mismo tratamiento que un 5xx. + status = 0; + detalle = String(e); + } + + const reintentable = status === 0 || status === 429 || status >= 500; + if (!reintentable || intento === INTENTOS) { + console.error(`[whatsapp] error ${status} al enviar mensaje (intento ${intento}): ${detalle}`); + return; + } + + // Backoff exponencial con jitter para no sincronizar los reintentos. + const espera = ESPERA_BASE_MS * 2 ** (intento - 1) + Math.floor(Math.random() * 250); + console.warn(`[whatsapp] error ${status}, reintento ${intento + 1}/${INTENTOS} en ${espera}ms`); + await esperar(espera); } } -- 2.52.0 From 2df679777c29398104add75f9667d651870756d9 Mon Sep 17 00:00:00 2001 From: Beldor Date: Wed, 5 Aug 2026 12:21:02 -0300 Subject: [PATCH 6/6] fix(jefe): mensaje de duracion acorde a lo que valida La validacion rechaza 0 pero el mensaje decia "entre 0 y 24". Ojo que "entre 1 y 24" habria sido igual de incorrecto: duracionHoras es Float y la UI muestra las duraciones de menos de una hora en minutos, asi que 0,5 (media hora) es un valor valido y esperado. Co-Authored-By: Claude Opus 5 --- src/app/api/jefe/servicios/[id]/route.ts | 2 +- src/app/api/jefe/servicios/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/jefe/servicios/[id]/route.ts b/src/app/api/jefe/servicios/[id]/route.ts index 4c159f4..3a4e4e5 100644 --- a/src/app/api/jefe/servicios/[id]/route.ts +++ b/src/app/api/jefe/servicios/[id]/route.ts @@ -20,7 +20,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const dur = Number(data.duracionHoras); if (!Number.isFinite(dur) || dur <= 0 || dur > 24) return NextResponse.json( - { error: "La duración debe ser un número de horas entre 0 y 24" }, + { error: "La duración debe ser mayor que 0 y hasta 24 horas" }, { status: 400 } ); } diff --git a/src/app/api/jefe/servicios/route.ts b/src/app/api/jefe/servicios/route.ts index a4c6617..f861c55 100644 --- a/src/app/api/jefe/servicios/route.ts +++ b/src/app/api/jefe/servicios/route.ts @@ -28,7 +28,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 }); if (!Number.isFinite(dur) || dur <= 0 || dur > 24) return NextResponse.json( - { error: "La duración debe ser un número de horas entre 0 y 24" }, + { error: "La duración debe ser mayor que 0 y hasta 24 horas" }, { status: 400 } ); -- 2.52.0