91112862a5
Revisión con IA / ai-review (pull_request) Successful in 29s
- 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 <noreply@anthropic.com>
112 lines
3.6 KiB
TypeScript
112 lines
3.6 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { Prisma } from "@prisma/client";
|
|
import { prisma } from "@/lib/prisma";
|
|
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, 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;
|
|
|
|
async function yaProcesado(id: string): Promise<boolean> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
await limpiarProcesadosViejos();
|
|
|
|
for (const mensaje of mensajesDelPayload(body)) {
|
|
const telefono = mensaje.from;
|
|
const messageId = mensaje.id;
|
|
if (!telefono || !messageId || (await 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 });
|
|
}
|