feat: bot de reservas por WhatsApp (F0) y flujo web jefe -> trabajo #4

Merged
jpbeldomenico merged 6 commits from feat/whatsapp-bot into main 2026-08-05 12:24:55 -03:00
23 changed files with 1759 additions and 115 deletions
+10
View File
@@ -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=""
+51
View File
@@ -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).
+3 -1
View File
@@ -10,7 +10,9 @@
"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",
Review

JSON does not support comments; '# added' will cause parsing errors.

    "db:studio": "prisma studio",

#ai-review-inline

JSON does not support comments; '# added' will cause parsing errors. ```suggestion "db:studio": "prisma studio", ``` #ai-review-inline
"test:bot": "tsx scripts/test-whatsapp-bot.ts",
Review

JSON does not support comments; '# added' will cause parsing errors.

    "test:bot": "tsx scripts/test-whatsapp-bot.ts",

#ai-review-inline

JSON does not support comments; '# added' will cause parsing errors. ```suggestion "test:bot": "tsx scripts/test-whatsapp-bot.ts", ``` #ai-review-inline
"test:envio": "tsx scripts/test-whatsapp-envio.ts"
Review

JSON does not support comments; '# added' will cause parsing errors.

    "test:envio": "tsx scripts/test-whatsapp-envio.ts"

#ai-review-inline

JSON does not support comments; '# added' will cause parsing errors. ```suggestion "test:envio": "tsx scripts/test-whatsapp-envio.ts" ``` #ai-review-inline
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
+48 -19
View File
@@ -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
Review

Making email optional with @unique allows multiple NULL values in some databases (PostgreSQL allows multiple NULLs with unique constraint). Consider if multiple clients without email is intended.

#ai-review-inline

Making email optional with @unique allows multiple NULL values in some databases (PostgreSQL allows multiple NULLs with unique constraint). Consider if multiple clients without email is intended. #ai-review-inline
nombre String
apellido String
telefono String
telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp
Review

telefonoNorm has @unique but is optional. Multiple clients could have NULL telefonoNorm. Consider if this is intended or if it should be required when provided.

#ai-review-inline

telefonoNorm has @unique but is optional. Multiple clients could have NULL telefonoNorm. Consider if this is intended or if it should be required when provided. #ai-review-inline
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)
Review

jefeMecanicoId is optional on TipoServicio. Ensure the relation JefeMecanico? handles null correctly in application code when jefeMecanicoId is null.

#ai-review-inline

jefeMecanicoId is optional on TipoServicio. Ensure the relation JefeMecanico? handles null correctly in application code when jefeMecanicoId is null. #ai-review-inline
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?
Review

autoId is now optional but Turno still requires tipoServicioId and jefeMecanicoId. Ensure business logic handles appointments without a vehicle correctly.

#ai-review-inline

autoId is now optional but Turno still requires tipoServicioId and jefeMecanicoId. Ensure business logic handles appointments without a vehicle correctly. #ai-review-inline
tipoServicioId String
jefeMecanicoId String
fechaInicio DateTime
fechaFin DateTime
kilometros Int
kilometros Int?
Review

kilometros is now optional. Consider if appointments without mileage tracking are valid for your business logic.

#ai-review-inline

kilometros is now optional. Consider if appointments without mileage tracking are valid for your business logic. #ai-review-inline
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,27 @@ 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 '+')
Review

telefono field uses wa_id (E.164 without '+'). Consider adding a comment about the exact format expected (e.g., '5491123456789') for clarity.

#ai-review-inline

telefono field uses wa_id (E.164 without '+'). Consider adding a comment about the exact format expected (e.g., '5491123456789') for clarity. #ai-review-inline
paso String @default("INICIO") // paso de la máquina de estados
datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)
Review

datos field uses Json with default '{}'. Consider defining a TypeScript interface for the expected JSON structure to improve type safety.

#ai-review-inline

datos field uses Json with default '{}'. Consider defining a TypeScript interface for the expected JSON structure to improve type safety. #ai-review-inline
clienteId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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
Review

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

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
createdAt DateTime @default(now())
@@index([createdAt])
Review

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
}
+270
View File
@@ -0,0 +1,270 @@
/**
* 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 */
Review

ESLint disable for entire file hides potential issues; consider narrowing to specific lines or using // eslint-disable-next-line where needed.

#ai-review-inline

ESLint disable for entire file hides potential issues; consider narrowing to specific lines or using // eslint-disable-next-line where needed. #ai-review-inline
type Row = Record<string, any>;
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;
}
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;
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];
Review

orderBy handling assumes single-field object with string direction; Prisma supports multiple fields and complex orderBy structures.

#ai-review-inline

orderBy handling assumes single-field object with string direction; Prisma supports multiple fields and complex orderBy structures. #ai-review-inline
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();
Review

Direct mutation of row.updatedAt affects the stored object; consider creating a new object to avoid side effects.

      const updated = { ...r, ...data, updatedAt: new Date() };
      Object.assign(r, updated);
      return { ...updated };

#ai-review-inline

Direct mutation of row.updatedAt affects the stored object; consider creating a new object to avoid side effects. ```suggestion const updated = { ...r, ...data, updatedAt: new Date() }; Object.assign(r, updated); return { ...updated }; ``` #ai-review-inline
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 };
Review

Spread order (defaults, where, create) may cause where clauses (e.g., id) to override create data; create should take precedence.

      const nuevo = { ...defaults(), ...create, ...where };

#ai-review-inline

Spread order (defaults, where, create) may cause where clauses (e.g., id) to override create data; create should take precedence. ```suggestion const nuevo = { ...defaults(), ...create, ...where }; ``` #ai-review-inline
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;
},
},
// `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) {
Review

$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;
// ── 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" });
// 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",
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");
Review

Dynamic import after global prisma assignment is correct but fragile; consider a setup function to make intent clearer.

#ai-review-inline

Dynamic import after global prisma assignment is correct but fragile; consider a setup function to make intent clearer. #ai-review-inline
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(/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");
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── 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");
Review

Importing crearTurno inside main() after tests start is unconventional; move to top with other imports (after global prisma set).

#ai-review-inline

Importing crearTurno inside main() after tests start is unconventional; move to top with other imports (after global prisma set). #ai-review-inline
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);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+181
View File
@@ -0,0 +1,181 @@
/**
* 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[] = [];
// 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({
url: String(url),
auth: headers["Authorization"] ?? "",
body: JSON.parse(String(init?.body ?? "{}")),
});
const status = respuestas.shift() ?? 200;
return { ok: status < 400, status, text: async () => "detalle" } 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── 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");
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);
});
+17 -2
View File
@@ -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({
Review

Potential race condition: another request could modify autoId/kilometros between this read and the update at line 28.

#ai-review-inline

Potential race condition: another request could modify autoId/kilometros between this read and the update at line 28. #ai-review-inline
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) {
Review

Redundant check: validation at lines 15-26 already guarantees autoId and kilometros exist when reaching this line.

if (estado === "COMPLETADO") {

#ai-review-inline

Redundant check: validation at lines 15-26 already guarantees autoId and kilometros exist when reaching this line. ```suggestion if (estado === "COMPLETADO") { ``` #ai-review-inline
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 ?? "",
Review

Good: nullish coalescing handles missing email gracefully for sheet export.

#ai-review-inline

Good: nullish coalescing handles missing email gracefully for sheet export. #ai-review-inline
telefono: turno.cliente.telefono,
fecha: turno.fechaInicio,
trabajo: turno.tipoServicio.nombre,
+18 -3
View File
@@ -1,13 +1,28 @@
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)
Review

Add validation for jefeMecanicoId format (e.g., UUID) to prevent invalid queries.

if (!jefeMecanicoId || !/^[0-9a-f-]{36}$/i.test(jefeMecanicoId))

#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)
Review

Consider different error codes: 404 for not found, 403 for inactive jefe.

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

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: { activo: true },
where: whereTrabajosDeJefe(jefeMecanicoId),
Review

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" },
});
+3
View File
@@ -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))
Review

Consider using 403 Forbidden instead of 400 Bad Request since the mechanic exists but doesn't perform this service type.

if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
    return NextResponse.json({ error: "Ese trabajo no lo hace este mecánico" }, { status: 403 });

#ai-review-inline

Consider using 403 Forbidden instead of 400 Bad Request since the mechanic exists but doesn't perform this service type. ```suggestion if (!trabajoEsDeJefe(servicio, jefeMecanicoId)) return NextResponse.json({ error: "Ese trabajo no lo hace este mecánico" }, { status: 403 }); ``` #ai-review-inline
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);
+12 -28
View File
@@ -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";
Review

Verify that crearTurno encapsulates all removed validations (auto, servicio, jefeMecanico, slot availability) to avoid regression.

#ai-review-inline

Verify that crearTurno encapsulates all removed validations (auto, servicio, jefeMecanico, slot availability) to avoid regression. #ai-review-inline
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,
Review

Ensure 'km' variable is defined in scope (was in removed code); consider adding a fallback or validation.

#ai-review-inline

Ensure 'km' variable is defined in scope (was in removed code); consider adding a fallback or validation. #ai-review-inline
});
return NextResponse.json(turno, { status: 201 });
if (!resultado.ok)
Review

Confirm resultado.status returns appropriate HTTP codes (e.g., 404, 409) for each error case.

#ai-review-inline

Confirm resultado.status returns appropriate HTTP codes (e.g., 404, 409) for each error case. #ai-review-inline
return NextResponse.json({ error: resultado.error }, { status: resultado.status });
return NextResponse.json(resultado.turno, { status: 201 });
Review

Ensure resultado.turno includes the same include relations (auto, tipoServicio, jefeMecanico) as before for client compatibility.

#ai-review-inline

Ensure resultado.turno includes the same include relations (auto, tipoServicio, jefeMecanico) as before for client compatibility. #ai-review-inline
}
+66
View File
@@ -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)
Review

Duration validation rejects 0 but error message says 'entre 0 y 24'. Should be 'entre 1 y 24' or allow 0 with dur < 0.

if (!Number.isFinite(dur) || dur < 0 || dur > 24)

#ai-review-inline

Duration validation rejects 0 but error message says 'entre 0 y 24'. Should be 'entre 1 y 24' or allow 0 with `dur < 0`. ```suggestion if (!Number.isFinite(dur) || dur < 0 || dur > 24) ``` #ai-review-inline
return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" },
Outdated
Review

Error message inconsistent with validation logic (allows 0 vs rejects 0).

{ error: "La duración debe ser un número de horas entre 1 y 24" }

#ai-review-inline

Error message inconsistent with validation logic (allows 0 vs rejects 0). ```suggestion { error: "La duración debe ser un número de horas entre 1 y 24" } ``` #ai-review-inline
{ 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 } });
Review

Potential race condition: another request could create a turno between count and delete/update. Consider using a transaction or handling FK constraint error.

#ai-review-inline

Potential race condition: another request could create a turno between count and delete/update. Consider using a transaction or handling FK constraint error. #ai-review-inline
if (enUso > 0) {
const servicio = await prisma.tipoServicio.update({
where: { id },
data: { activo: false },
});
return NextResponse.json({ ...servicio, desactivado: true });
Review

Response includes custom desactivado property not in Prisma model. Ensure frontend handles this.

#ai-review-inline

Response includes custom `desactivado` property not in Prisma model. Ensure frontend handles this. #ai-review-inline
}
await prisma.tipoServicio.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+40
View File
@@ -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({
Review

No pagination on findMany; could return large dataset. Consider adding take/skip or cursor pagination.

#ai-review-inline

No pagination on findMany; could return large dataset. Consider adding take/skip or cursor pagination. #ai-review-inline
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();
Review

req.json() throws on invalid JSON, resulting in 500. Wrap in try-catch to return 400.

  let nombre, duracionHoras;
  try {
    ({ nombre, duracionHoras } = await req.json());
  } catch {
    return NextResponse.json({ error: "Cuerpo de solicitud inválido" }, { status: 400 });
  }

#ai-review-inline

req.json() throws on invalid JSON, resulting in 500. Wrap in try-catch to return 400. ```suggestion let nombre, duracionHoras; try { ({ nombre, duracionHoras } = await req.json()); } catch { return NextResponse.json({ error: "Cuerpo de solicitud inválido" }, { status: 400 }); } ``` #ai-review-inline
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)
Review

Error message says 'entre 0 y 24' but validation rejects 0 (dur <= 0). Update message to 'entre 1 y 24' or adjust validation to allow 0.

      { error: "La duración debe ser un número de horas entre 1 y 24" },

#ai-review-inline

Error message says 'entre 0 y 24' but validation rejects 0 (dur <= 0). Update message to 'entre 1 y 24' or adjust validation to allow 0. ```suggestion { error: "La duración debe ser un número de horas entre 1 y 24" }, ``` #ai-review-inline
Review

Magic number 24 for max hours. Define as constant for maintainability.

  const MAX_HOURS = 24;
  if (!Number.isFinite(dur) || dur <= 0 || dur > MAX_HOURS)

#ai-review-inline

Magic number 24 for max hours. Define as constant for maintainability. ```suggestion const MAX_HOURS = 24; if (!Number.isFinite(dur) || dur <= 0 || dur > MAX_HOURS) ``` #ai-review-inline
return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" },
{ status: 400 }
);
const servicio = await prisma.tipoServicio.create({
Review

Prisma create can throw (e.g., unique constraint). Add try-catch to return 400/500 appropriately.

  try {
    const servicio = await prisma.tipoServicio.create({
      data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub },
    });
    return NextResponse.json(servicio, { status: 201 });
  } catch (error) {
    return NextResponse.json({ error: "Error al crear el servicio" }, { status: 500 });
  }

#ai-review-inline

Prisma create can throw (e.g., unique constraint). Add try-catch to return 400/500 appropriately. ```suggestion try { const servicio = await prisma.tipoServicio.create({ data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub }, }); return NextResponse.json(servicio, { status: 201 }); } catch (error) { return NextResponse.json({ error: "Error al crear el servicio" }, { status: 500 }); } ``` #ai-review-inline
data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub },
});
return NextResponse.json(servicio, { status: 201 });
}
+111
View File
@@ -0,0 +1,111 @@
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;
Review

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
async function yaProcesado(id: string): Promise<boolean> {
Review

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> {
Review

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);
}
}
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"))) {
Review

Header 'x-hub-signature-256' may be undefined; firmaValida should handle missing header explicitly or check here before calling.

if (!req.headers.get('x-hub-signature-256') || !firmaValida(cuerpoCrudo, req.headers.get('x-hub-signature-256'))) {

#ai-review-inline

Header 'x-hub-signature-256' may be undefined; firmaValida should handle missing header explicitly or check here before calling. ```suggestion if (!req.headers.get('x-hub-signature-256') || !firmaValida(cuerpoCrudo, req.headers.get('x-hub-signature-256'))) { ``` #ai-review-inline
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;
Review

Type assertion without runtime validation; if Meta changes payload structure this will fail silently. Use a schema validator (e.g., zod) to parse and validate the webhook payload.

#ai-review-inline

Type assertion without runtime validation; if Meta changes payload structure this will fail silently. Use a schema validator (e.g., zod) to parse and validate the webhook payload. #ai-review-inline
} 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(() => {});
Review

Fire-and-forget with empty catch hides potential issues marking messages as read; at least log the error for observability.

marcarLeido(messageId).catch((e) => console.error('[whatsapp] failed to mark read:', e));

#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
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) {
Review

Catch-all returns 200 to avoid Meta retries, but errors are only logged. Add alerting/monitoring (e.g., Sentry) so failures don't go unnoticed.

#ai-review-inline

Catch-all returns 200 to avoid Meta retries, but errors are only logged. Add alerting/monitoring (e.g., Sentry) so failures don't go unnoticed. #ai-review-inline
// Nunca devolvemos error a Meta: reintentaría el mismo mensaje en loop.
console.error("[whatsapp] error procesando webhook:", e);
}
return NextResponse.json({ ok: true });
}
+69 -50
View File
@@ -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<Step>("servicio");
const [step, setStep] = useState<Step>("jefe");
const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [loadingServicios, setLoadingServicios] = useState(false);
const [jefes, setJefes] = useState<Jefe[]>([]);
const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
const [loadingJefes, setLoadingJefes] = useState(false);
const [loadingJefes, setLoadingJefes] = useState(true);
const [slots, setSlots] = useState<Slot[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
@@ -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() {
<div className="text-5xl">✅</div>
<h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1>
<Card className="text-left w-full max-w-sm">
<p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
{turnoCreado.jefeMecanico && (
<>
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p>
<p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p>
</>
)}
<p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
<p className="text-sm text-gray-500 mb-1">Fecha</p>
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
@@ -236,33 +245,46 @@ function TurnosContent() {
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
{step === "calendario" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : step === "jefe" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : step === "servicio" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : (
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
)}
<h1 className="text-xl sm:text-2xl font-bold text-white">
{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}` : ""}`}
</h1>
</div>
{/* STEP: servicio */}
{step === "servicio" && (
{/* STEP: jefe */}
{step === "jefe" && (
<>
<div className="grid gap-3">
{servicios.map(s => (
<button key={s.id} onClick={() => seleccionarServicio(s)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))}
<div className="flex flex-col gap-3">
{loadingJefes ? (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
) : jefes.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 text-center">
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
</p>
</Card>
) : (
jefes.map((j) => (
<button
key={j.id}
onClick={() => seleccionarJefe(j)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
>
<div>
<p className="font-medium text-gray-800">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver sus trabajos</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))
)}
</div>
{!loadingTurnos && misTurnos.length > 0 && (
@@ -288,27 +310,24 @@ function TurnosContent() {
</>
)}
{/* STEP: jefe */}
{step === "jefe" && (
<div className="flex flex-col gap-3">
{loadingJefes ? (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
) : jefes.length === 0 ? (
{/* STEP: servicio */}
{step === "servicio" && (
<div className="grid gap-3">
{loadingServicios ? (
<p className="text-center text-sm text-gray-200">Cargando trabajos...</p>
) : servicios.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 text-center">
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
{jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico.
</p>
</Card>
) : (
jefes.map((j) => (
<button
key={j.id}
onClick={() => seleccionarJefe(j)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
>
servicios.map(s => (
<button key={s.id} onClick={() => seleccionarServicio(s)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver disponibilidad</p>
<p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
+5 -5
View File
@@ -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() {
<span className="text-gray-400">Teléfono</span>
<span>{t.cliente.telefono}</span>
<span className="text-gray-400">Auto</span>
<span>{t.auto.marca} {t.auto.modelo}</span>
<span>{t.auto ? `${t.auto.marca} ${t.auto.modelo}` : <em className="text-amber-600">a completar</em>}</span>
Review

Inconsistent return types in ternary: string vs React element. Use a string for both branches or wrap both in a fragment.

{t.auto ? `${t.auto.marca} ${t.auto.modelo}` : "a completar"}

#ai-review-inline

Inconsistent return types in ternary: string vs React element. Use a string for both branches or wrap both in a fragment. ```suggestion {t.auto ? `${t.auto.marca} ${t.auto.modelo}` : "a completar"} ``` #ai-review-inline
<span className="text-gray-400">Patente</span>
<span className="font-mono">{t.auto.patente}</span>
<span className="font-mono">{t.auto?.patente ?? "—"}</span>
<span className="text-gray-400">Kilometraje</span>
<span>{t.kilometros.toLocaleString("es-AR")} km</span>
<span>{t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : <em className="text-amber-600">a completar</em>}</span>
Review

Inconsistent return types in ternary: string vs React element. Use a string for both branches.

{t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : "a completar"}

#ai-review-inline

Inconsistent return types in ternary: string vs React element. Use a string for both branches. ```suggestion {t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : "a completar"} ``` #ai-review-inline
</div>
{t.comentario && (
+23
View File
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
if (pathname === "/jefe") return <>{children}</>;
const tabs = [
Review

Consider moving the tabs array outside the component to avoid recreating it on every render.

const tabs = [
  { href: '/jefe/dashboard', label: 'Turnos' },
  { href: '/jefe/servicios', label: 'Trabajos' },
];

export default function Layout({ children }: { children: React.ReactNode }) {

#ai-review-inline

Consider moving the tabs array outside the component to avoid recreating it on every render. ```suggestion const tabs = [ { href: '/jefe/dashboard', label: 'Turnos' }, { href: '/jefe/servicios', label: 'Trabajos' }, ]; export default function Layout({ children }: { children: React.ReactNode }) { ``` #ai-review-inline
{ href: "/jefe/dashboard", label: "Turnos" },
{ href: "/jefe/servicios", label: "Trabajos" },
];
return (
<div className="min-h-screen flex flex-col">
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col py-6 gap-4">
@@ -24,6 +29,24 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
Cerrar sesión
</button>
</div>
<nav className="flex gap-1">
{tabs.map((t) => {
const activo = pathname === t.href;
Review

Exact pathname match may not highlight parent tab for nested routes (e.g., /jefe/dashboard/123). Consider using pathname.startsWith(t.href).

const activo = pathname === t.href || pathname.startsWith(t.href + '/');

#ai-review-inline

Exact pathname match may not highlight parent tab for nested routes (e.g., /jefe/dashboard/123). Consider using pathname.startsWith(t.href). ```suggestion const activo = pathname === t.href || pathname.startsWith(t.href + '/'); ``` #ai-review-inline
return (
<button
key={t.href}
onClick={() => router.push(t.href)}
Review

Use Next.js Link component for navigation instead of button with router.push for better accessibility, SEO, and prefetching.

<Link
  key={t.href}
  href={t.href}
  className={`text-sm px-4 py-2 rounded-lg transition $
    activo
      ? 'bg-white/15 text-white font-medium'
      : 'text-gray-300 hover:text-white hover:bg-white/10'
  }`}
>
  {t.label}
</Link>

#ai-review-inline

Use Next.js Link component for navigation instead of button with router.push for better accessibility, SEO, and prefetching. ```suggestion <Link key={t.href} href={t.href} className={`text-sm px-4 py-2 rounded-lg transition $ activo ? 'bg-white/15 text-white font-medium' : 'text-gray-300 hover:text-white hover:bg-white/10' }`} > {t.label} </Link> ``` #ai-review-inline
className={`text-sm px-4 py-2 rounded-lg transition ${
Review

ClassName template literal is hard to read. Extract conditional classes into a variable or use a helper like clsx.

const base = 'text-sm px-4 py-2 rounded-lg transition';
const active = 'bg-white/15 text-white font-medium';
const inactive = 'text-gray-300 hover:text-white hover:bg-white/10';
className={`${base} ${activo ? active : inactive}`}

#ai-review-inline

ClassName template literal is hard to read. Extract conditional classes into a variable or use a helper like clsx. ```suggestion const base = 'text-sm px-4 py-2 rounded-lg transition'; const active = 'bg-white/15 text-white font-medium'; const inactive = 'text-gray-300 hover:text-white hover:bg-white/10'; className={`${base} ${activo ? active : inactive}`} ``` #ai-review-inline
activo
? "bg-white/15 text-white font-medium"
: "text-gray-300 hover:text-white hover:bg-white/10"
}`}
>
{t.label}
</button>
);
})}
</nav>
<main className="flex-1">{children}</main>
</div>
</div>
+159
View File
@@ -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<Servicio[]>([]);
const [loading, setLoading] = useState(true);
const [nombre, setNombre] = useState("");
const [duracion, setDuracion] = useState("");
const [error, setError] = useState<string | null>(null);
const [guardando, setGuardando] = useState(false);
useEffect(() => { cargar(); }, []);
Review

useEffect depends on cargar which is recreated each render; wrap cargar in useCallback or add to deps.

const cargar = useCallback(async () => {
    setLoading(true);
    try {
      const res = await fetch("/api/jefe/servicios");
      if (!res.ok) throw new Error("Failed to fetch");
      const data = await res.json();
      setServicios(Array.isArray(data) ? data : []);
    } catch {
      setServicios([]);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { cargar(); }, [cargar]);

#ai-review-inline

useEffect depends on cargar which is recreated each render; wrap cargar in useCallback or add to deps. ```suggestion const cargar = useCallback(async () => { setLoading(true); try { const res = await fetch("/api/jefe/servicios"); if (!res.ok) throw new Error("Failed to fetch"); const data = await res.json(); setServicios(Array.isArray(data) ? data : []); } catch { setServicios([]); } finally { setLoading(false); } }, []); useEffect(() => { cargar(); }, [cargar]); ``` #ai-review-inline
async function cargar() {
Review

cargar() doesn't handle network errors or non-ok responses; add error handling.

async function cargar() {
    setLoading(true);
    try {
      const res = await fetch("/api/jefe/servicios");
      if (!res.ok) throw new Error("Failed to fetch");
      const data = await res.json();
      setServicios(Array.isArray(data) ? data : []);
    } catch {
      setServicios([]);
    } finally {
      setLoading(false);
    }
  }

#ai-review-inline

cargar() doesn't handle network errors or non-ok responses; add error handling. ```suggestion async function cargar() { setLoading(true); try { const res = await fetch("/api/jefe/servicios"); if (!res.ok) throw new Error("Failed to fetch"); const data = await res.json(); setServicios(Array.isArray(data) ? data : []); } catch { setServicios([]); } finally { setLoading(false); } } ``` #ai-review-inline
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 }),
Review

duracion is a string but duracionHoras expects a number; parse it before sending.

body: JSON.stringify({ nombre, duracionHoras: parseFloat(duracion) || 0 }),

#ai-review-inline

duracion is a string but duracionHoras expects a number; parse it before sending. ```suggestion body: JSON.stringify({ nombre, duracionHoras: parseFloat(duracion) || 0 }), ``` #ai-review-inline
});
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) {
Review

toggleActivo doesn't handle errors; UI may become out of sync if request fails.

async function toggleActivo(s: Servicio) {
    try {
      const res = await fetch(`/api/jefe/servicios/${s.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ activo: !s.activo }),
      });
      if (!res.ok) throw new Error("Failed to toggle");
      cargar();
    } catch {
      alert("No se pudo cambiar el estado");
    }
  }

#ai-review-inline

toggleActivo doesn't handle errors; UI may become out of sync if request fails. ```suggestion async function toggleActivo(s: Servicio) { try { const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ activo: !s.activo }), }); if (!res.ok) throw new Error("Failed to toggle"); cargar(); } catch { alert("No se pudo cambiar el estado"); } } ``` #ai-review-inline
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 }),
Review

valor from prompt is a string; parse to number for duracionHoras.

body: JSON.stringify({ duracionHoras: parseFloat(valor) }),

#ai-review-inline

valor from prompt is a string; parse to number for duracionHoras. ```suggestion body: JSON.stringify({ duracionHoras: parseFloat(valor) }), ``` #ai-review-inline
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error ?? "No se pudo actualizar");
return;
}
cargar();
}
async function eliminar(s: Servicio) {
Review

eliminar doesn't check res.ok before parsing; may miss errors.

async function eliminar(s: Servicio) {
    if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return;
    try {
      const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" });
      const d = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(d.error ?? "No se pudo eliminar");
      if (d.desactivado) alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse.");
      cargar();
    } catch (e) {
      alert(e instanceof Error ? e.message : "Error desconocido");
    }
  }

#ai-review-inline

eliminar doesn't check res.ok before parsing; may miss errors. ```suggestion async function eliminar(s: Servicio) { if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return; try { const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" }); const d = await res.json().catch(() => ({})); if (!res.ok) throw new Error(d.error ?? "No se pudo eliminar"); if (d.desactivado) alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse."); cargar(); } catch (e) { alert(e instanceof Error ? e.message : "Error desconocido"); } } ``` #ai-review-inline
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 (
<div className="flex flex-col gap-6">
<h2 className="text-xl sm:text-2xl font-bold text-white">Mis trabajos y tiempos</h2>
<Card>
<form onSubmit={agregar} className="flex flex-col gap-3">
<p className="text-sm text-gray-600">
Cargá los trabajos que ofrecés y cuánto tardan. Son los que verá el cliente al reservar por WhatsApp.
</p>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-3 items-end">
<Input
label="Trabajo"
placeholder="Ej: Cambio de aceite"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
/>
<Input
label="Duración (horas)"
type="text"
Review

Input uses type="text" with inputMode="decimal"; consider type="number" for better validation.

type="number"

#ai-review-inline

Input uses type="text" with inputMode="decimal"; consider type="number" for better validation. ```suggestion type="number" ``` #ai-review-inline
inputMode="decimal"
placeholder="Ej: 1.5"
value={duracion}
onChange={(e) => setDuracion(e.target.value)}
className="sm:w-40"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div>
<Button type="submit" loading={guardando}>Agregar trabajo</Button>
</div>
</form>
</Card>
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : servicios.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">Todavía no cargaste ningún trabajo.</p>
</Card>
) : (
<div className="flex flex-col gap-3">
{servicios.map((s) => (
<Card key={s.id}>
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<p className={`font-semibold ${s.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{s.nombre}
</p>
<p className="text-sm text-gray-500 mt-0.5">
{s.duracionHoras} {s.duracionHoras === 1 ? "hora" : "horas"}
{!s.activo && " · inactivo"}
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="text-sm" onClick={() => editarDuracion(s)}>
Review

Action buttons lack loading/disabled states; users may trigger multiple requests.

#ai-review-inline

Action buttons lack loading/disabled states; users may trigger multiple requests. #ai-review-inline
Duración
</Button>
<Button variant="secondary" className="text-sm" onClick={() => toggleActivo(s)}>
{s.activo ? "Desactivar" : "Activar"}
</Button>
<Button variant="danger" className="text-sm" onClick={() => eliminar(s)}>
Eliminar
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
+17 -7
View File
@@ -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;
Review

Consider exporting ClienteDb type if it will be reused in other modules; otherwise, keep it private with a leading underscore or JSDoc @internal.

type ClienteDb = Prisma.TransactionClient;

#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
Review

The default parameter db = prisma works because PrismaClient satisfies TransactionClient, but add a comment to clarify this intentional covariance for future maintainers.

  jefeMecanicoId: string,
  db: ClienteDb = prisma // PrismaClient extends TransactionClient
): Promise<number> {

#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;
}
+24
View File
@@ -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 {
Review

Function name could be more descriptive (e.g., whereTrabajosDisponiblesParaJefe) to indicate it includes global jobs.

#ai-review-inline

Function name could be more descriptive (e.g., whereTrabajosDisponiblesParaJefe) to indicate it includes global jobs. #ai-review-inline
return {
activo: true,
OR: [{ jefeMecanicoId }, { jefeMecanicoId: null }],
Review

Consider using explicit property syntax for clarity: { jefeMecanicoId: jefeMecanicoId }

    OR: [{ jefeMecanicoId: jefeMecanicoId }, { jefeMecanicoId: null }],

#ai-review-inline

Consider using explicit property syntax for clarity: { jefeMecanicoId: jefeMecanicoId } ```suggestion OR: [{ jefeMecanicoId: jefeMecanicoId }, { jefeMecanicoId: null }], ``` #ai-review-inline
};
}
/** Un trabajo propio de otro jefe no se puede reservar con este. Los globales sí. */
export function trabajoEsDeJefe(
servicio: Pick<TipoServicio, "jefeMecanicoId">,
jefeMecanicoId: string
): boolean {
return servicio.jefeMecanicoId === null || servicio.jefeMecanicoId === jefeMecanicoId;
}
+8
View File
@@ -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, "");
}
+70
View File
@@ -0,0 +1,70 @@
import { prisma } from "./prisma";
import { slotEstaDisponible } from "./scheduling";
import { trabajoEsDeJefe } from "./servicios";
export interface CrearTurnoInput {
clienteId: string;
jefeMecanicoId: string;
tipoServicioId: string;
fechaInicio: Date;
autoId?: string | null;
kilometros?: number | null;
}
export type CrearTurnoResultado =
| { ok: true; turno: Awaited<ReturnType<typeof prisma.turno.create>> }
| { 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<CrearTurnoResultado> {
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 };
// Un trabajo propio de otro jefe no se reserva con éste (los globales sí).
if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
return { ok: false, error: "Ese trabajo no lo hace este mecánico", status: 400 };
const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
Review

servicio.duracionHoras might be null/undefined; add a guard or default to avoid NaN in fechaFin.

const duracion = servicio.duracionHoras ?? 1;
  const fin = new Date(fechaInicio.getTime() + duracion * 60 * 60 * 1000);

#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
// 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}))`;
Review

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')).

await tx.$executeRaw`SELECT pg_advisory_xact_lock(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 disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId, tx);
if (!disponible) return null;
Review

Returning null from transaction callback to signal conflict is unconventional; consider throwing a specific error or returning a result object for clarity.

if (!disponible) throw new Error('SLOT_UNAVAILABLE');

#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 };
Review

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 };
}
+373
View File
@@ -0,0 +1,373 @@
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
import { obtenerSlotsDisponibles } from "./scheduling";
import { crearTurno } from "./turnos";
import { normalizarTelefono } from "./telefono";
import { trabajoEsDeJefe, whereTrabajosDeJefe } from "./servicios";
// ── 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) }));
Review

Buttons only show first 3 mechanics while text lists all; users can't select mechanics beyond the first 3 via buttons.

const botones: BotonBot[] = jefes.map((j) => ({ id: j.id, titulo: j.nombre.slice(0, 20) }));

#ai-review-inline

Buttons only show first 3 mechanics while text lists all; users can't select mechanics beyond the first 3 via buttons. ```suggestion const botones: BotonBot[] = jefes.map((j) => ({ id: j.id, titulo: j.nombre.slice(0, 20) })); ``` #ai-review-inline
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: whereTrabajosDeJefe(datos.jefeMecanicoId!),
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));
Review

Mutating opciones array after listaConLetras returns it creates hidden side effect; assign ids during mapping instead.

const { texto, opciones } = listaConLetras(servicios.map((s, i) => ({ label: `${s.nombre} (${s.duracionHoras}h)`, id: s.id })));

#ai-review-inline

Mutating opciones array after listaConLetras returns it creates hidden side effect; assign ids during mapping instead. ```suggestion const { texto, opciones } = listaConLetras(servicios.map((s, i) => ({ label: `${s.nombre} (${s.duracionHoras}h)`, id: s.id }))); ``` #ai-review-inline
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);
Review

Magic numbers 30 and 200 for days ahead and max slots; extract to named constants for clarity.

const DIAS_ADELANTE = 30; const MAX_SLOTS = 200; const slots = await obtenerSlotsDisponibles(datos.jefeMecanicoId!, datos.duracionHoras ?? 1, DIAS_ADELANTE, MAX_SLOTS);

#ai-review-inline

Magic numbers 30 and 200 for days ahead and max slots; extract to named constants for clarity. ```suggestion const DIAS_ADELANTE = 30; const MAX_SLOTS = 200; const slots = await obtenerSlotsDisponibles(datos.jefeMecanicoId!, datos.duracionHoras ?? 1, DIAS_ADELANTE, MAX_SLOTS); ``` #ai-review-inline
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(),
Review

Using ISO string as option id but elegirOpcion lowercases it; ISO strings contain uppercase letters (T, Z) causing mismatch when user types the letter.

id: s.fechaInicio.toISOString().toLowerCase(),

#ai-review-inline

Using ISO string as option id but elegirOpcion lowercases it; ISO strings contain uppercase letters (T, Z) causing mismatch when user types the letter. ```suggestion id: s.fechaInicio.toISOString().toLowerCase(), ``` #ai-review-inline
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,
Review

Unsafe cast 'as unknown as Prisma.InputJsonValue' bypasses type safety; use proper JSON serialization or Prisma's JsonValue type.

datos: datos as Prisma.InputJsonValue,

#ai-review-inline

Unsafe cast 'as unknown as Prisma.InputJsonValue' bypasses type safety; use proper JSON serialization or Prisma's JsonValue type. ```suggestion datos: datos as Prisma.InputJsonValue, ``` #ai-review-inline
paso: string,
datos: DatosConv,
resp: RespuestaBot[],
clienteId?: string | null
): Promise<RespuestaBot[]> {
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<RespuestaBot[]> {
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") {
Review

Global 'no' cancels even during VERIFICAR_EMAIL step where user might be answering 'no' to a different question; restrict cancel to specific steps.

const pasosCancelables = ['ELEGIR_MECANICO', 'ELEGIR_TRABAJO', 'ELEGIR_HORARIO', 'ALTA_NOMBRE', 'ALTA_APELLIDO', 'ALTA_EMAIL']; if (NO.includes(lower) && pasosCancelables.includes(paso)) {

#ai-review-inline

Global 'no' cancels even during VERIFICAR_EMAIL step where user might be answering 'no' to a different question; restrict cancel to specific steps. ```suggestion const pasosCancelables = ['ELEGIR_MECANICO', 'ELEGIR_TRABAJO', 'ELEGIR_HORARIO', 'ALTA_NOMBRE', 'ALTA_APELLIDO', 'ALTA_EMAIL']; if (NO.includes(lower) && pasosCancelables.includes(paso)) { ``` #ai-review-inline
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 } });
Review

Using findFirst for telefonoNorm which should be unique; use findUnique with proper unique index for clarity and performance.

const cliente = await prisma.cliente.findUnique({ where: { telefonoNorm: telefono } });

#ai-review-inline

Using findFirst for telefonoNorm which should be unique; use findUnique with proper unique index for clarity and performance. ```suggestion const cliente = await prisma.cliente.findUnique({ where: { telefonoNorm: telefono } }); ``` #ai-review-inline
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()) {
Review

Comparing lowercased input with lowercased email is correct but redundant since 'lower' is already lowercased; simplify.

if (cliente?.email && lower === cliente.email.toLowerCase()) {

#ai-review-inline

Comparing lowercased input with lowercased email is correct but redundant since 'lower' is already lowercased; simplify. ```suggestion if (cliente?.email && lower === cliente.email.toLowerCase()) { ``` #ai-review-inline
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({
Review

Setting both telefono and telefonoNorm to same normalized value; ensure schema requires both or remove redundancy.

#ai-review-inline

Setting both telefono and telefonoNorm to same normalized value; ensure schema requires both or remove redundancy. #ai-review-inline
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 || !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]);
}
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({
Review

Non-null assertion on conv.clienteId could crash if clientId is null (e.g., new client flow bug); add guard or handle gracefully.

if (!conv.clienteId) return guardar(conv.id, 'INICIO', {}, [{ tipo: 'texto', texto: 'Error: cliente no identificado. Iniciá de nuevo.' }]);

#ai-review-inline

Non-null assertion on conv.clienteId could crash if clientId is null (e.g., new client flow bug); add guard or handle gracefully. ```suggestion if (!conv.clienteId) return guardar(conv.id, 'INICIO', {}, [{ tipo: 'texto', texto: 'Error: cliente no identificado. Iniciá de nuevo.' }]); ``` #ai-review-inline
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 }]);
}
}
+181
View File
@@ -0,0 +1,181 @@
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 {
Review

firmaValida returns false when WHATSAPP_APP_SECRET is missing, which may hide configuration errors in production.

const appSecret = process.env.WHATSAPP_APP_SECRET;
  if (!appSecret) {
    console.error('[whatsapp] WHATSAPP_APP_SECRET not set; webhook validation will fail');
    return false;
  }

#ai-review-inline

firmaValida returns false when WHATSAPP_APP_SECRET is missing, which may hide configuration errors in production. ```suggestion const appSecret = process.env.WHATSAPP_APP_SECRET; if (!appSecret) { console.error('[whatsapp] WHATSAPP_APP_SECRET not set; webhook validation will fail'); return false; } ``` #ai-review-inline
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);
}
// 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> {
Review

postMensaje fails silently when credentials are missing; callers have no way to know the message wasn't sent.

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

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) {
console.error("[whatsapp] falta WHATSAPP_TOKEN o WHATSAPP_PHONE_NUMBER_ID; no se envía nada");
return;
}
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 }),
Review

Spread operator allows payload to overwrite messaging_product; use explicit object to avoid accidents.

body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),

#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
};
for (let intento = 1; intento <= INTENTOS; intento++) {
let status: number;
let detalle = "";
try {
const res = await fetch(url, init);
Review

fetch has no timeout; a stalled request can hang indefinitely.

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

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);
}
}
async function enviarTexto(telefono: string, texto: string): Promise<void> {
await postMensaje({
to: telefono,
type: "text",
text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },
Review

preview_url hardcoded to false; URLs in text messages won't show previews. Make configurable if needed.

text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },

#ai-review-inline

preview_url hardcoded to false; URLs in text messages won't show previews. Make configurable if needed. ```suggestion text: { body: texto.slice(0, MAX_TEXTO), preview_url: false }, ``` #ai-review-inline
});
}
async function enviarBotones(
telefono: string,
texto: string,
botones: { id: string; titulo: string }[]
): Promise<void> {
// 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<void> {
Review

enviarRespuestas ignores individual send failures; a failed message won't stop the loop but errors are lost.

export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise<void> {
  for (const r of respuestas) {
    try {
      if (r.tipo === 'botones') await enviarBotones(telefono, r.texto, r.botones);
      else await enviarTexto(telefono, r.texto);
    } catch (e) {
      console.error('[whatsapp] failed to send response:', e);
      // decide: continue or break
    }
  }
}

#ai-review-inline

enviarRespuestas ignores individual send failures; a failed message won't stop the loop but errors are lost. ```suggestion export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise<void> { for (const r of respuestas) { try { if (r.tipo === 'botones') await enviarBotones(telefono, r.texto, r.botones); else await enviarTexto(telefono, r.texto); } catch (e) { console.error('[whatsapp] failed to send response:', e); // decide: continue or break } } } ``` #ai-review-inline
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<void> {
Review

marcarLeido reuses postMensaje which is semantically for sending messages; consider a dedicated low-level POST helper.

async function postRaw(payload: Record<string, unknown>): Promise<void> {
  // same retry logic as postMensaje but without messaging_product injection
}

export async function marcarLeido(messageId: string): Promise<void> {
  await postRaw({ messaging_product: 'whatsapp', status: 'read', message_id: messageId });
}

#ai-review-inline

marcarLeido reuses postMensaje which is semantically for sending messages; consider a dedicated low-level POST helper. ```suggestion async function postRaw(payload: Record<string, unknown>): Promise<void> { // same retry logic as postMensaje but without messaging_product injection } export async function marcarLeido(messageId: string): Promise<void> { await postRaw({ messaging_product: 'whatsapp', status: 'read', message_id: messageId }); } ``` #ai-review-inline
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 {
Review

textoDeMensaje returns button id for interactive but button text for legacy button type; inconsistent for callers.

if (mensaje.type === 'interactive') {
    const i = mensaje.interactive ?? {};
    return i.button_reply?.id ?? i.list_reply?.id ?? null;
  }
  if (mensaje.type === 'button') return mensaje.button?.text ?? null;
  // Consider normalizing: always return the payload id/value the bot expects.

#ai-review-inline

textoDeMensaje returns button id for interactive but button text for legacy button type; inconsistent for callers. ```suggestion if (mensaje.type === 'interactive') { const i = mensaje.interactive ?? {}; return i.button_reply?.id ?? i.list_reply?.id ?? null; } if (mensaje.type === 'button') return mensaje.button?.text ?? null; // Consider normalizing: always return the payload id/value the bot expects. ``` #ai-review-inline
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;
}