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