fix: los cuatro hallazgos reales de la revision con IA
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>
This commit is contained in:
Beldor
2026-08-05 11:21:35 -03:00
parent 44aeb58e77
commit 91112862a5
8 changed files with 168 additions and 44 deletions
+10
View File
@@ -136,3 +136,13 @@ model ConversacionWhatsapp {
cliente Cliente? @relation(fields: [clienteId], references: [id]) 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])
}
+8
View File
@@ -138,6 +138,14 @@ const fakePrisma: any = {
return out; 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; (globalThis as any).prisma = fakePrisma;
+31 -1
View File
@@ -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 ── // ── 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 }; type Llamada = { url: string; auth: string; body: any };
let llamadas: Llamada[] = []; 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) => { globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>; const headers = (init?.headers ?? {}) as Record<string, string>;
llamadas.push({ llamadas.push({
@@ -39,7 +41,8 @@ globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
auth: headers["Authorization"] ?? "", auth: headers["Authorization"] ?? "",
body: JSON.parse(String(init?.body ?? "{}")), 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; }) as typeof fetch;
async function main() { async function main() {
@@ -97,6 +100,33 @@ async function main() {
ok(llamadas.length === 2, "manda las respuestas múltiples en orden"); 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"); 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 ──"); console.log("\n── Firma del webhook ──");
const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" }); const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" });
const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex"); const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex");
+8
View File
@@ -13,6 +13,14 @@ export async function GET(req: NextRequest) {
if (!jefeMecanicoId) if (!jefeMecanicoId)
return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 }); 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({ const servicios = await prisma.tipoServicio.findMany({
where: whereTrabajosDeJefe(jefeMecanicoId), where: whereTrabajosDeJefe(jefeMecanicoId),
orderBy: { nombre: "asc" }, orderBy: { nombre: "asc" },
+31 -14
View File
@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { procesarMensaje } from "@/lib/whatsapp-bot"; import { procesarMensaje } from "@/lib/whatsapp-bot";
import { import {
enviarRespuestas, 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. * 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 // Meta reintenta el mismo mensaje si tardamos o si algo falla, y sin deduplicar eso
// procesados en memoria (un solo container) para no duplicar turnos ni respuestas. // crea el turno dos veces. Se resuelve insertando el id en la base: el índice único
const procesados = new Set<string>(); // hace la operación atómica, así que sirve incluso con varias instancias corriendo.
const MAX_PROCESADOS = 500; const DIAS_RETENCION = 7;
let ultimaLimpieza = 0;
function yaProcesado(id: string): boolean { async function yaProcesado(id: string): Promise<boolean> {
if (procesados.has(id)) return true; try {
procesados.add(id); await prisma.mensajeWhatsappProcesado.create({ data: { 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; 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) { export async function GET(req: NextRequest) {
@@ -64,10 +79,12 @@ export async function POST(req: NextRequest) {
} }
try { try {
await limpiarProcesadosViejos();
for (const mensaje of mensajesDelPayload(body)) { for (const mensaje of mensajesDelPayload(body)) {
const telefono = mensaje.from; const telefono = mensaje.from;
const messageId = mensaje.id; const messageId = mensaje.id;
if (!telefono || !messageId || yaProcesado(messageId)) continue; if (!telefono || !messageId || (await yaProcesado(messageId))) continue;
void marcarLeido(messageId).catch(() => {}); void marcarLeido(messageId).catch(() => {});
+17 -7
View File
@@ -1,5 +1,13 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "./prisma"; 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_H = 8;
const INICIO_M = 30; const INICIO_M = 30;
const FIN_H = 17; const FIN_H = 17;
@@ -53,9 +61,10 @@ function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
async function turnosSolapados( async function turnosSolapados(
inicio: Date, inicio: Date,
fin: Date, fin: Date,
jefeMecanicoId: string jefeMecanicoId: string,
db: ClienteDb = prisma
): Promise<number> { ): Promise<number> {
const count = await prisma.turno.count({ const count = await db.turno.count({
where: { where: {
jefeMecanicoId, jefeMecanicoId,
estado: { in: ["PENDIENTE", "CONFIRMADO"] }, estado: { in: ["PENDIENTE", "CONFIRMADO"] },
@@ -65,8 +74,8 @@ async function turnosSolapados(
return count; return count;
} }
async function obtenerJefeActivo(jefeMecanicoId: string) { async function obtenerJefeActivo(jefeMecanicoId: string, db: ClienteDb = prisma) {
return prisma.jefeMecanico.findUnique({ return db.jefeMecanico.findUnique({
where: { id: jefeMecanicoId }, where: { id: jefeMecanicoId },
select: { slotsParalelos: true, activo: true }, select: { slotsParalelos: true, activo: true },
}); });
@@ -133,11 +142,12 @@ export async function obtenerSlotsDisponibles(
export async function slotEstaDisponible( export async function slotEstaDisponible(
inicio: Date, inicio: Date,
fin: Date, fin: Date,
jefeMecanicoId: string jefeMecanicoId: string,
db: ClienteDb = prisma
): Promise<boolean> { ): Promise<boolean> {
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false; 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; 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; return ocupados < jefe.slotsParalelos;
} }
+12 -3
View File
@@ -37,10 +37,16 @@ export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResu
const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000); const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId); // Verificar y crear en una sola transacción, serializando por jefe con un lock
if (!disponible) return { ok: false, error: "El horario ya no está disponible", status: 409 }; // de Postgres: si no, dos clientes pueden pasar la verificación al mismo tiempo
// y quedar los dos reservados sobre la misma capacidad.
const turno = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${jefeMecanicoId}))`;
const turno = await prisma.turno.create({ const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId, tx);
if (!disponible) return null;
return tx.turno.create({
data: { data: {
clienteId, clienteId,
jefeMecanicoId, jefeMecanicoId,
@@ -56,6 +62,9 @@ export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResu
jefeMecanico: { select: { id: true, nombre: 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 }; return { ok: true, turno };
} }
+37 -5
View File
@@ -42,6 +42,16 @@ export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): bo
return crypto.timingSafeEqual(a, b); 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<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function postMensaje(payload: Record<string, unknown>): Promise<void> { async function postMensaje(payload: Record<string, unknown>): Promise<void> {
const cred = credenciales(); const cred = credenciales();
if (!cred) { if (!cred) {
@@ -49,18 +59,40 @@ async function postMensaje(payload: Record<string, unknown>): Promise<void> {
return; 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", method: "POST",
headers: { headers: {
Authorization: `Bearer ${cred.token}`, Authorization: `Bearer ${cred.token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ messaging_product: "whatsapp", ...payload }), body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),
}); };
if (!res.ok) { for (let intento = 1; intento <= INTENTOS; intento++) {
const detalle = await res.text().catch(() => ""); let status: number;
console.error(`[whatsapp] error ${res.status} al enviar mensaje: ${detalle}`); 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);
} }
} }