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_USER="tu-email@gmail.com"
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
MAGIC_LINK_BASE_URL="http://localhost:3000" 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:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "npx tsx prisma/seed.ts", "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": { "prisma": {
"seed": "npx tsx prisma/seed.ts" "seed": "npx tsx prisma/seed.ts"
+48 -19
View File
@@ -7,16 +7,18 @@ datasource db {
} }
model Cliente { model Cliente {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique 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 nombre String
apellido String apellido String
telefono String telefono String
createdAt DateTime @default(now()) 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[] autos Auto[]
turnos Turno[] turnos Turno[]
magicTokens MagicToken[] magicTokens MagicToken[]
conversaciones ConversacionWhatsapp[]
} }
model Auto { model Auto {
@@ -35,30 +37,32 @@ model Auto {
} }
model TipoServicio { model TipoServicio {
id String @id @default(cuid()) id String @id @default(cuid())
nombre String nombre String
duracionHoras Float duracionHoras Float
activo Boolean @default(true) 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[] jefeMecanico JefeMecanico? @relation(fields: [jefeMecanicoId], references: [id])
historial HistorialServicio[] turnos Turno[]
historial HistorialServicio[]
} }
model Turno { model Turno {
id String @id @default(cuid()) id String @id @default(cuid())
clienteId String 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 tipoServicioId String
jefeMecanicoId String jefeMecanicoId String
fechaInicio DateTime fechaInicio DateTime
fechaFin 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) estado EstadoTurno @default(PENDIENTE)
comentario String? comentario String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id]) 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]) tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id])
jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id]) jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id])
} }
@@ -72,7 +76,8 @@ model JefeMecanico {
activo Boolean @default(true) activo Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
turnos Turno[] turnos Turno[]
servicios TipoServicio[]
} }
model HistorialServicio { model HistorialServicio {
@@ -117,3 +122,27 @@ enum EstadoTurno {
CANCELADO CANCELADO
COMPLETADO 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 { id } = await params;
const { estado, comentario } = await req.json(); 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({ const turno = await prisma.turno.update({
where: { id }, where: { id },
data: { estado, ...(comentario !== undefined && { comentario }) }, data: { estado, ...(comentario !== undefined && { comentario }) },
include: { auto: true, tipoServicio: true, cliente: true }, 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 { try {
await agregarFilaSheet({ await agregarFilaSheet({
patente: turno.auto.patente, patente: turno.auto.patente,
@@ -26,7 +41,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
anio: turno.auto.anio, anio: turno.auto.anio,
nombre: turno.cliente.nombre, nombre: turno.cliente.nombre,
apellido: turno.cliente.apellido, 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, telefono: turno.cliente.telefono,
fecha: turno.fechaInicio, fecha: turno.fechaInicio,
trabajo: turno.tipoServicio.nombre, 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 { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; 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"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); 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({ 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" }, orderBy: { nombre: "asc" },
}); });
+3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
import { obtenerSlotsDisponibles } from "@/lib/scheduling"; import { obtenerSlotsDisponibles } from "@/lib/scheduling";
import { trabajoEsDeJefe } from "@/lib/servicios";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); 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 } }); const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo) if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 }); 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); const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500);
return NextResponse.json(slots); return NextResponse.json(slots);
+12 -28
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; 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() { export async function GET() {
const sesion = await verificarSesion("cliente_session"); 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 }); if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } }); const resultado = await crearTurno({
if (!servicio || !servicio.activo) clienteId: sesion.sub,
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 }); jefeMecanicoId,
tipoServicioId,
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } }); fechaInicio: new Date(fechaInicio),
if (!jefe || !jefe.activo) autoId,
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 }); 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
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 } } },
}); });
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 MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]; 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) { function formatDuracion(h: number) {
if (h < 1) return `${Math.round(h * 60)} min`; if (h < 1) return `${Math.round(h * 60)} min`;
@@ -66,12 +68,13 @@ function TurnosContent() {
const params = useSearchParams(); const params = useSearchParams();
const autoId = params.get("autoId"); const autoId = params.get("autoId");
const [step, setStep] = useState<Step>("servicio"); const [step, setStep] = useState<Step>("jefe");
const [servicios, setServicios] = useState<TipoServicio[]>([]); const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null); const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [loadingServicios, setLoadingServicios] = useState(false);
const [jefes, setJefes] = useState<Jefe[]>([]); const [jefes, setJefes] = useState<Jefe[]>([]);
const [jefeSel, setJefeSel] = useState<Jefe | null>(null); const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
const [loadingJefes, setLoadingJefes] = useState(false); const [loadingJefes, setLoadingJefes] = useState(true);
const [slots, setSlots] = useState<Slot[]>([]); const [slots, setSlots] = useState<Slot[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false); const [loadingSlots, setLoadingSlots] = useState(false);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]); const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
@@ -90,7 +93,12 @@ function TurnosContent() {
useEffect(() => { useEffect(() => {
if (!autoId) { router.push("/cliente/dashboard"); return; } 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") fetch("/api/cliente/turnos")
.then(r => r.json()) .then(r => r.json())
.then((data) => { .then((data) => {
@@ -110,29 +118,30 @@ function TurnosContent() {
return m; return m;
}, [slots]); }, [slots]);
async function seleccionarServicio(s: TipoServicio) { async function seleccionarJefe(j: Jefe) {
setServicioSel(s); setJefeSel(j);
setJefeSel(null); setServicioSel(null);
setServicios([]);
setSlots([]); setSlots([]);
setDiaSeleccionado(null); setDiaSeleccionado(null);
setSlotSel(null); setSlotSel(null);
setLoadingJefes(true); setLoadingServicios(true);
setStep("jefe"); setStep("servicio");
const res = await fetch("/api/cliente/jefes"); const res = await fetch(`/api/cliente/servicios?jefeMecanicoId=${j.id}`);
const data = await res.json(); const data = await res.json();
setJefes(Array.isArray(data) ? data : []); setServicios(Array.isArray(data) ? data : []);
setLoadingJefes(false); setLoadingServicios(false);
} }
async function seleccionarJefe(j: Jefe) { async function seleccionarServicio(s: TipoServicio) {
if (!servicioSel) return; if (!jefeSel) return;
setJefeSel(j); setServicioSel(s);
setLoadingSlots(true); setLoadingSlots(true);
setSlots([]); setSlots([]);
setDiaSeleccionado(null); setDiaSeleccionado(null);
setSlotSel(null); setSlotSel(null);
setStep("calendario"); 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(); const data = await res.json();
setSlots(Array.isArray(data) ? data : []); setSlots(Array.isArray(data) ? data : []);
setLoadingSlots(false); setLoadingSlots(false);
@@ -213,14 +222,14 @@ function TurnosContent() {
<div className="text-5xl">✅</div> <div className="text-5xl">✅</div>
<h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1> <h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1>
<Card className="text-left w-full max-w-sm"> <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 && ( {turnoCreado.jefeMecanico && (
<> <>
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p> <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="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="text-sm text-gray-500 mb-1">Fecha</p>
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", { <p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long" 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 flex-col gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{step === "calendario" ? ( {step === "calendario" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : step === "jefe" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button> <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> <Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
)} )}
<h1 className="text-xl sm:text-2xl font-bold text-white"> <h1 className="text-xl sm:text-2xl font-bold text-white">
{step === "servicio" && "Elegí el servicio"}
{step === "jefe" && "Elegí el jefe mecánico"} {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> </h1>
</div> </div>
{/* STEP: servicio */} {/* STEP: jefe */}
{step === "servicio" && ( {step === "jefe" && (
<> <>
<div className="grid gap-3"> <div className="flex flex-col gap-3">
{servicios.map(s => ( {loadingJefes ? (
<button key={s.id} onClick={() => seleccionarServicio(s)} <p className="text-center text-sm text-gray-200">Cargando jefes...</p>
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"> ) : jefes.length === 0 ? (
<div> <Card>
<p className="font-medium text-gray-800">{s.nombre}</p> <p className="text-sm text-gray-600 text-center">
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p> No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
</div> </p>
<span className="text-gray-400 text-lg">→</span> </Card>
</button> ) : (
))} 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> </div>
{!loadingTurnos && misTurnos.length > 0 && ( {!loadingTurnos && misTurnos.length > 0 && (
@@ -288,27 +310,24 @@ function TurnosContent() {
</> </>
)} )}
{/* STEP: jefe */} {/* STEP: servicio */}
{step === "jefe" && ( {step === "servicio" && (
<div className="flex flex-col gap-3"> <div className="grid gap-3">
{loadingJefes ? ( {loadingServicios ? (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p> <p className="text-center text-sm text-gray-200">Cargando trabajos...</p>
) : jefes.length === 0 ? ( ) : servicios.length === 0 ? (
<Card> <Card>
<p className="text-sm text-gray-600 text-center"> <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> </p>
</Card> </Card>
) : ( ) : (
jefes.map((j) => ( servicios.map(s => (
<button <button key={s.id} onClick={() => seleccionarServicio(s)}
key={j.id} 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">
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> <div>
<p className="font-medium text-gray-800">{j.nombre}</p> <p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver disponibilidad</p> <p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
</div> </div>
<span className="text-gray-400 text-lg">→</span> <span className="text-gray-400 text-lg">→</span>
</button> </button>
+5 -5
View File
@@ -9,9 +9,9 @@ interface Turno {
fechaFin: string; fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO"; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
comentario: string | null; comentario: string | null;
kilometros: number; kilometros: number | null;
tipoServicio: { nombre: string }; 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 }; cliente: { nombre: string; apellido: string; telefono: string };
} }
@@ -81,11 +81,11 @@ export default function JefeDashboard() {
<span className="text-gray-400">Teléfono</span> <span className="text-gray-400">Teléfono</span>
<span>{t.cliente.telefono}</span> <span>{t.cliente.telefono}</span>
<span className="text-gray-400">Auto</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="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 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> </div>
{t.comentario && ( {t.comentario && (
+23
View File
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
if (pathname === "/jefe") return <>{children}</>; 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 ( return (
<div className="min-h-screen flex flex-col"> <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"> <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 Cerrar sesión
</button> </button>
</div> </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> <main className="flex-1">{children}</main>
</div> </div>
</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"; 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_H = 8;
const INICIO_M = 30; const INICIO_M = 30;
const FIN_H = 17; const FIN_H = 17;
@@ -53,9 +61,10 @@ function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
async function turnosSolapados( async function turnosSolapados(
inicio: Date, inicio: Date,
fin: Date, fin: Date,
jefeMecanicoId: string jefeMecanicoId: string,
db: ClienteDb = prisma
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> { ): Promise<number> {
const count = await prisma.turno.count({ const count = await db.turno.count({
where: { where: {
jefeMecanicoId, jefeMecanicoId,
estado: { in: ["PENDIENTE", "CONFIRMADO"] }, estado: { in: ["PENDIENTE", "CONFIRMADO"] },
@@ -65,8 +74,8 @@ async function turnosSolapados(
return count; return count;
} }
async function obtenerJefeActivo(jefeMecanicoId: string) { async function obtenerJefeActivo(jefeMecanicoId: string, db: ClienteDb = prisma) {
return prisma.jefeMecanico.findUnique({ return db.jefeMecanico.findUnique({
where: { id: jefeMecanicoId }, where: { id: jefeMecanicoId },
select: { slotsParalelos: true, activo: true }, select: { slotsParalelos: true, activo: true },
}); });
@@ -133,11 +142,12 @@ export async function obtenerSlotsDisponibles(
export async function slotEstaDisponible( export async function slotEstaDisponible(
inicio: Date, inicio: Date,
fin: Date, fin: Date,
jefeMecanicoId: string jefeMecanicoId: string,
db: ClienteDb = prisma
): Promise<boolean> { ): Promise<boolean> {
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false; if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
const jefe = await obtenerJefeActivo(jefeMecanicoId); const jefe = await obtenerJefeActivo(jefeMecanicoId, db);
if (!jefe || !jefe.activo) return false; if (!jefe || !jefe.activo) return false;
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId); const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId, db);
return ocupados < jefe.slotsParalelos; return ocupados < jefe.slotsParalelos;
} }
+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;
}