feat: bot de reservas por WhatsApp (F0) y flujo web jefe -> trabajo #4
@@ -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])
|
||||
|
ai-bot
commented
Index on createdAt for MensajeWhatsappProcesado is good for cleanup queries. Consider adding a TTL/index cleanup strategy documentation. #ai-review-inline Index on createdAt for MensajeWhatsappProcesado is good for cleanup queries. Consider adding a TTL/index cleanup strategy documentation.
#ai-review-inline
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
ai-bot
commented
$transaction mock doesn't simulate rollback or isolation; errors in fn won't revert changes made to in-memory stores. #ai-review-inline $transaction mock doesn't simulate rollback or isolation; errors in fn won't revert changes made to in-memory stores.
#ai-review-inline
|
||||
return fn(fakePrisma);
|
||||
},
|
||||
async $executeRaw() {
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
|
||||
(globalThis as any).prisma = fakePrisma;
|
||||
|
||||
@@ -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<string, string>;
|
||||
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");
|
||||
|
||||
@@ -13,6 +13,14 @@ export async function GET(req: NextRequest) {
|
||||
if (!jefeMecanicoId)
|
||||
|
ai-bot
commented
Add validation for jefeMecanicoId format (e.g., UUID) to prevent invalid queries. #ai-review-inline Add validation for jefeMecanicoId format (e.g., UUID) to prevent invalid queries.
```suggestion
if (!jefeMecanicoId || !/^[0-9a-f-]{36}$/i.test(jefeMecanicoId))
```
#ai-review-inline
|
||||
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)
|
||||
|
ai-bot
commented
Consider different error codes: 404 for not found, 403 for inactive jefe. #ai-review-inline Consider different error codes: 404 for not found, 403 for inactive jefe.
```suggestion
if (!jefe) return NextResponse.json({ error: 'Jefe mecánico no encontrado' }, { status: 404 });
if (!jefe.activo) return NextResponse.json({ error: 'Jefe mecánico no disponible' }, { status: 403 });
```
#ai-review-inline
|
||||
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 });
|
||||
|
||||
const servicios = await prisma.tipoServicio.findMany({
|
||||
where: whereTrabajosDeJefe(jefeMecanicoId),
|
||||
|
ai-bot
commented
Verify that whereTrabajosDeJefe returns correct where clause for TipoServicio model, not Trabajo model. #ai-review-inline Verify that whereTrabajosDeJefe returns correct where clause for TipoServicio model, not Trabajo model.
#ai-review-inline
|
||||
orderBy: { nombre: "asc" },
|
||||
|
||||
@@ -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<string>();
|
||||
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;
|
||||
|
ai-bot
commented
Module-level variable 'ultimaLimpieza' is not reliable in serverless/multi-instance environments; each instance has its own memory and cold starts reset it. Consider a cron job or DB-based cleanup instead. #ai-review-inline Module-level variable 'ultimaLimpieza' is not reliable in serverless/multi-instance environments; each instance has its own memory and cold starts reset it. Consider a cron job or DB-based cleanup instead.
#ai-review-inline
|
||||
|
||||
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<boolean> {
|
||||
|
ai-bot
commented
Function name 'yaProcesado' suggests it returns true if already processed, but it returns false on successful insert (new message). Consider renaming to 'registrarProcesado' or inverting logic for clarity. #ai-review-inline Function name 'yaProcesado' suggests it returns true if already processed, but it returns false on successful insert (new message). Consider renaming to 'registrarProcesado' or inverting logic for clarity.
#ai-review-inline
|
||||
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> {
|
||||
|
ai-bot
commented
Cleanup runs on every POST request adding latency; with the unreliable timer it may run too often or not at all. Move to a scheduled job (e.g., Vercel Cron) or a separate background worker. #ai-review-inline Cleanup runs on every POST request adding latency; with the unreliable timer it may run too often or not at all. Move to a scheduled job (e.g., Vercel Cron) or a separate background worker.
#ai-review-inline
|
||||
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(() => {});
|
||||
|
ai-bot
commented
Fire-and-forget with empty catch hides potential issues marking messages as read; at least log the error for observability. #ai-review-inline Fire-and-forget with empty catch hides potential issues marking messages as read; at least log the error for observability.
```suggestion
marcarLeido(messageId).catch((e) => console.error('[whatsapp] failed to mark read:', e));
```
#ai-review-inline
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
ai-bot
commented
Consider exporting ClienteDb type if it will be reused in other modules; otherwise, keep it private with a leading underscore or JSDoc @internal. #ai-review-inline Consider exporting ClienteDb type if it will be reused in other modules; otherwise, keep it private with a leading underscore or JSDoc @internal.
```suggestion
type ClienteDb = Prisma.TransactionClient;
```
#ai-review-inline
|
||||
|
||||
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
|
||||
|
ai-bot
commented
The default parameter #ai-review-inline The default parameter `db = prisma` works because PrismaClient satisfies TransactionClient, but add a comment to clarify this intentional covariance for future maintainers.
```suggestion
jefeMecanicoId: string,
db: ClienteDb = prisma // PrismaClient extends TransactionClient
): Promise<number> {
```
#ai-review-inline
|
||||
): Promise<number> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,25 +37,34 @@ export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResu
|
||||
|
||||
const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
|
||||
|
ai-bot
commented
servicio.duracionHoras might be null/undefined; add a guard or default to avoid NaN in fechaFin. #ai-review-inline servicio.duracionHoras might be null/undefined; add a guard or default to avoid NaN in fechaFin.
```suggestion
const duracion = servicio.duracionHoras ?? 1;
const fin = new Date(fechaInicio.getTime() + duracion * 60 * 60 * 1000);
```
#ai-review-inline
|
||||
|
||||
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId);
|
||||
if (!disponible) return { ok: false, error: "El horario ya no está disponible", status: 409 };
|
||||
// Verificar y crear en una sola transacción, serializando por jefe con un lock
|
||||
// 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}))`;
|
||||
|
ai-bot
commented
hashtext() returns a 32-bit integer; advisory lock collisions are possible under high concurrency. Consider using a 64-bit hash or a composite key (e.g., hashtext(jefeMecanicoId || 'turno')). #ai-review-inline hashtext() returns a 32-bit integer; advisory lock collisions are possible under high concurrency. Consider using a 64-bit hash or a composite key (e.g., hashtext(jefeMecanicoId || 'turno')).
```suggestion
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${jefeMecanicoId} || 'turno'))`;
```
#ai-review-inline
|
||||
|
||||
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;
|
||||
|
ai-bot
commented
Returning null from transaction callback to signal conflict is unconventional; consider throwing a specific error or returning a result object for clarity. #ai-review-inline Returning null from transaction callback to signal conflict is unconventional; consider throwing a specific error or returning a result object for clarity.
```suggestion
if (!disponible) throw new Error('SLOT_UNAVAILABLE');
```
#ai-review-inline
|
||||
|
||||
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 };
|
||||
|
ai-bot
commented
The null check catches the conflict but masks other potential null returns; narrow the check to the specific error thrown above. #ai-review-inline The null check catches the conflict but masks other potential null returns; narrow the check to the specific error thrown above.
#ai-review-inline
|
||||
|
||||
return { ok: true, turno };
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function postMensaje(payload: Record<string, unknown>): Promise<void> {
|
||||
|
ai-bot
commented
postMensaje fails silently when credentials are missing; callers have no way to know the message wasn't sent. #ai-review-inline postMensaje fails silently when credentials are missing; callers have no way to know the message wasn't sent.
```suggestion
async function postMensaje(payload: Record<string, unknown>): Promise<void> {
const cred = credenciales();
if (!cred) {
const err = new Error('WhatsApp credentials not configured');
console.error('[whatsapp]', err.message);
throw err;
}
// ... rest unchanged
```
#ai-review-inline
|
||||
const cred = credenciales();
|
||||
if (!cred) {
|
||||
@@ -49,18 +59,40 @@ async function postMensaje(payload: Record<string, unknown>): Promise<void> {
|
||||
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 }),
|
||||
|
ai-bot
commented
Spread operator allows payload to overwrite messaging_product; use explicit object to avoid accidents. #ai-review-inline Spread operator allows payload to overwrite messaging_product; use explicit object to avoid accidents.
```suggestion
body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
```
#ai-review-inline
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
|
ai-bot
commented
fetch has no timeout; a stalled request can hang indefinitely. #ai-review-inline fetch has no timeout; a stalled request can hang indefinitely.
```suggestion
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
```
#ai-review-inline
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
MensajeWhatsappProcesado uses wamid as id (String @id). Consider adding @default(cuid()) or keeping as-is since wamid is provided by Meta. Current approach is fine if wamid is guaranteed unique.
#ai-review-inline