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
Owner

Cierra el bot de reservas por WhatsApp y deja el flujo web coherente con los trabajos por jefe.

Bot de WhatsApp

Las fases F1 (schema), F3 (panel de trabajos del jefe) y F2 (maquina de conversacion) ya venian de antes en esta rama. Lo nuevo es F0, que la conecta con la API de Meta:

  • src/lib/whatsapp.ts: traduce RespuestaBot a payloads de la Graph API (texto e interactive/button, respetando los limites de 3 botones / 20 caracteres de titulo / 4096 de texto), aplana el payload entrante del webhook y valida la firma X-Hub-Signature-256 en tiempo constante.
  • src/app/api/whatsapp/webhook/route.ts: GET de verificacion y POST de mensajes. La firma se calcula sobre el cuerpo crudo, se deduplica por id de mensaje (Meta reintenta) y siempre se devuelve 200 para no entrar en un bucle de reintentos.
  • scripts/test-whatsapp-envio.ts (npm run test:envio): 30 asserts sobre la capa de Meta con fetch stubbeado, sin base de datos ni credenciales.
  • docs/whatsapp.md: alta en el panel de Meta y las cuatro variables de entorno.

Falta unicamente cargar las credenciales de Meta en el .env de produccion y dar de alta el webhook.

Flujo web: jefe entonces trabajo

Los trabajos pasaron a ser de cada jefe mecanico, pero la web seguia listando todos los servicios del taller y recien despues pedia el jefe.

  • src/lib/servicios.ts: regla unica de que trabajos ofrece un jefe, que son los suyos mas los globales del taller (jefeMecanicoId en null, los carga el admin). Esto evita romper produccion, donde hoy todos los servicios son globales.
  • /api/cliente/servicios ahora exige jefeMecanicoId y aplica esa regla.
  • /api/cliente/slots y crearTurno rechazan con 400 un trabajo propio de otro jefe. Antes se podia reservar con el jefe A un trabajo privado del jefe B, y como la duracion del turno la define el trabajo, eso le bloqueaba la agenda al A con un tiempo que no era suyo.
  • El bot usa la misma regla, asi la web y WhatsApp ofrecen lo mismo.
  • src/app/cliente/turnos/page.tsx: pasos invertidos, cabeceras y boton de volver acordes, y estado vacio propio cuando el jefe todavia no cargo trabajos.

Verificacion

npm run test:bot (31 asserts, incluye un escenario nuevo de trabajos por jefe), npm run test:envio (30 asserts), tsc, lint y next build: todo limpio. El flujo en navegador real no se probo porque no hay Postgres local levantado.

Antes de mergear

El entrypoint corre prisma db push --accept-data-loss y esta rama cambia el schema (telefonoNorm unico, Turno.autoId y kilometros opcionales). Arranca en null, asi que es seguro, pero conviene backup de la base primero.

🤖 Generated with Claude Code

Cierra el bot de reservas por WhatsApp y deja el flujo web coherente con los trabajos por jefe. ## Bot de WhatsApp Las fases F1 (schema), F3 (panel de trabajos del jefe) y F2 (maquina de conversacion) ya venian de antes en esta rama. Lo nuevo es **F0**, que la conecta con la API de Meta: - `src/lib/whatsapp.ts`: traduce `RespuestaBot` a payloads de la Graph API (texto e `interactive/button`, respetando los limites de 3 botones / 20 caracteres de titulo / 4096 de texto), aplana el payload entrante del webhook y valida la firma `X-Hub-Signature-256` en tiempo constante. - `src/app/api/whatsapp/webhook/route.ts`: GET de verificacion y POST de mensajes. La firma se calcula sobre el cuerpo crudo, se deduplica por id de mensaje (Meta reintenta) y siempre se devuelve 200 para no entrar en un bucle de reintentos. - `scripts/test-whatsapp-envio.ts` (`npm run test:envio`): 30 asserts sobre la capa de Meta con `fetch` stubbeado, sin base de datos ni credenciales. - `docs/whatsapp.md`: alta en el panel de Meta y las cuatro variables de entorno. Falta unicamente cargar las credenciales de Meta en el `.env` de produccion y dar de alta el webhook. ## Flujo web: jefe entonces trabajo Los trabajos pasaron a ser de cada jefe mecanico, pero la web seguia listando todos los servicios del taller y recien despues pedia el jefe. - `src/lib/servicios.ts`: regla unica de que trabajos ofrece un jefe, que son los suyos mas los globales del taller (`jefeMecanicoId` en null, los carga el admin). Esto evita romper produccion, donde hoy todos los servicios son globales. - `/api/cliente/servicios` ahora exige `jefeMecanicoId` y aplica esa regla. - `/api/cliente/slots` y `crearTurno` rechazan con 400 un trabajo propio de otro jefe. Antes se podia reservar con el jefe A un trabajo privado del jefe B, y como la duracion del turno la define el trabajo, eso le bloqueaba la agenda al A con un tiempo que no era suyo. - El bot usa la misma regla, asi la web y WhatsApp ofrecen lo mismo. - `src/app/cliente/turnos/page.tsx`: pasos invertidos, cabeceras y boton de volver acordes, y estado vacio propio cuando el jefe todavia no cargo trabajos. ## Verificacion `npm run test:bot` (31 asserts, incluye un escenario nuevo de trabajos por jefe), `npm run test:envio` (30 asserts), `tsc`, `lint` y `next build`: todo limpio. El flujo en navegador real no se probo porque no hay Postgres local levantado. ## Antes de mergear El entrypoint corre `prisma db push --accept-data-loss` y esta rama cambia el schema (`telefonoNorm` unico, `Turno.autoId` y `kilometros` opcionales). Arranca en null, asi que es seguro, pero conviene backup de la base primero. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jpbeldomenico added 4 commits 2026-08-05 09:57:35 -03:00
FASE 1 — schema:
- Cliente.email opcional; nuevo telefonoNorm (E.164, único) para matchear por WhatsApp
- TipoServicio.jefeMecanicoId opcional (trabajos per-jefe; globales legacy quedan null)
- Turno.autoId/kilometros opcionales (reservas por WhatsApp sin auto/km)
- Nuevo modelo ConversacionWhatsapp (estado del bot por teléfono)
- admin/turnos/[id]: no permite COMPLETADO sin auto+km; Sheets tolera email nulo

FASE 3 — panel de trabajos del jefe:
- API jefe/servicios (GET/POST) y jefe/servicios/[id] (PATCH/DELETE), scoped al jefe
- DELETE desactiva si el trabajo ya tiene turnos asociados
- Página /jefe/servicios (alta/listado/activar/editar/eliminar) + nav Turnos|Trabajos
- Dashboard del jefe resiliente a turnos sin auto/km ("a completar")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- lib/turnos.ts: crearTurno() compartido (valida servicio/jefe/slot); cliente/turnos POST lo reutiliza
- lib/telefono.ts: normalización de teléfono (E.164 sin '+') para matchear el wa_id
- lib/whatsapp-bot.ts: procesarMensaje(telefono, texto), state machine completa:
  identidad por teléfono + verificación por email registrado / alta nueva,
  elegir mecánico (botones) -> trabajo (letras) -> horario (letras, con "más") -> confirmar,
  crea turno sin auto/km; expiración 30min, "cancelar" global, manejo de inválidos y 409.
  Devuelve objetos-respuesta (texto/botones) -> independiente de Meta (lo traduce F0).
- scripts/test-whatsapp-bot.ts + npm run test:bot: corre la máquina de estados con un
  Prisma en memoria (sin DB ni Meta), ejercitando scheduling y crearTurno reales. 27/27 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ultima fase del bot de reservas: conecta la maquina de conversacion ya
existente (whatsapp-bot.ts) con la WhatsApp Business Cloud API.

- src/lib/whatsapp.ts: traduce RespuestaBot -> payloads de Meta (texto e
  interactive/button, con los limites de 3 botones / 20 chars / 4096 chars),
  aplana el payload del webhook y valida X-Hub-Signature-256 en tiempo
  constante.
- src/app/api/whatsapp/webhook/route.ts: GET de verificacion y POST de
  mensajes. Valida la firma sobre el cuerpo crudo, deduplica por message id
  (Meta reintenta) y siempre devuelve 200 para no entrar en loop de retries.
- scripts/test-whatsapp-envio.ts (npm run test:envio): 30 asserts sobre la
  capa Meta con fetch stubbeado, sin DB ni credenciales.
- docs/whatsapp.md con el alta en el panel de Meta y las 4 env vars.

Falta solo cargar las credenciales de Meta en el .env de produccion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(cliente): invertir el flujo web a jefe -> trabajo
Revisión con IA / ai-review (pull_request) Failing after 2m12s
44aeb58e77
Los trabajos ahora son por jefe mecanico, asi que el cliente elige primero
el mecanico y despues el trabajo, igual que en el bot de WhatsApp. Antes
listaba todos los servicios del taller y recien despues pedia el jefe.

- src/lib/servicios.ts: regla unica de que trabajos ofrece un jefe = los
  suyos + los globales del taller (jefeMecanicoId null, los carga el admin).
  Asi no se rompe lo que ya existe en produccion, donde todos los servicios
  son globales.
- /api/cliente/servicios ahora exige jefeMecanicoId y aplica esa regla.
- /api/cliente/slots y crearTurno rechazan un trabajo propio de OTRO jefe
  (400): antes se podia reservar con el jefe A la duracion del trabajo del B.
- whatsapp-bot: usa la misma regla, asi la web y el bot ofrecen lo mismo.
- cliente/turnos: pasos invertidos, cabeceras y back acordes, y estado vacio
  propio cuando el jefe no tiene trabajos.
- test-whatsapp-bot: soporte de OR en el Prisma en memoria, trabajo global en
  el seed y escenario D (globales visibles, trabajo ajeno rechazado). 31 asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jpbeldomenico added 1 commit 2026-08-05 11:21:40 -03:00
fix: los cuatro hallazgos reales de la revision con IA
Revisión con IA / ai-review (pull_request) Successful in 29s
91112862a5
- crearTurno verificaba disponibilidad y creaba el turno en dos operaciones
  sueltas: dos clientes podian pasar la verificacion a la vez y sobrevender el
  mismo horario. Ahora va todo en una transaccion, serializada por jefe con
  pg_advisory_xact_lock. slotEstaDisponible acepta el cliente de la transaccion.
  Probado contra un Postgres real: 5 reservas simultaneas del mismo slot con
  capacidad 1 dan 1 ok + 4 conflictos y una sola fila; sacando el lock, entran
  las 5.
- La deduplicacion del webhook estaba en memoria y solo servia con una
  instancia. Pasa a la base con el modelo MensajeWhatsappProcesado: el insert
  del id es atomico por el indice unico, asi que aguanta reintentos
  concurrentes de Meta (verificado). Se limpian los ids de mas de 7 dias.
- postMensaje no reintentaba: ahora reintenta 3 veces ante 429, 5xx y fallas
  de red, con backoff exponencial y jitter. Un 4xx nuestro no se reintenta.
- /api/cliente/servicios devolvia los trabajos de un jefe dado de baja; ahora
  responde 404 si el jefe no existe o no esta activo.

test:envio suma los casos de reintento (35 asserts). El Prisma en memoria del
test del bot ahora entiende $transaction y $executeRaw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator

Qué cambia
Se agrega un bot de reservas por WhatsApp (Meta Cloud API) que permite al cliente dar de alta, elegir mecánico, trabajo y horario, y confirmar el turno dentro de la ventana de 24 hs sin plantillas. Incluye: nuevos modelos ConversacionWhatsapp (estado de charla) y MensajeWhatsappProcesado (deduplicación por wamid), cambios de esquema (Cliente.email opcional, telefonoNorm único, TipoServicio.jefeMecanicoId nullable para trabajos globales, Turno.autoId/kilometros opcionales), endpoints de webhook (GET verificación, POST mensajes con firma HMAC y lock por jefe), máquina de estados en whatsapp-bot.ts, cliente Meta en whatsapp.ts con reintentos y truncado a límites de la API, creación de turnos centralizada con pg_advisory_xact_lock, tests sin BD ni credenciales (test:bot, test:envio), y ajustes en el front (flujo cliente: jefe → trabajo; pestaña "Trabajos" en panel de jefe).

Riesgos

  • Migración de BD: varios ALTER (nullable, unique, nuevas tablas). Verificar que no rompe datos existentes y que el unique en telefonoNorm no choca con duplicados previos.
  • Webhook sin WHATSAPP_APP_SECRET: rechaza todo con 403 (a propósito), pero si falta en prod el bot queda mudo sin error visible.
  • Lock asesor (pg_advisory_xact_lock): serializa por jefeMecanicoId; bajo carga alta puede contender. Revisar que no haya deadlocks y que el timeout de transacción sea razonable.
  • Idempotencia: MensajeWhatsappProcesado usa PK en wamid; limpieza diaria best-effort. Si el índice único falla por duplicados previos (improbable), el webhook tiraría excepción.
  • Campos ahora opcionales: código viejo que asuma Turno.autoId o kilometros requeridos puede romper (p. ej. exportaciones a Sheets).
  • Límites de Meta: botones máx 3, título 20 chars, texto 4096; el bot recorta silenciosamente. Confirmar que el flujo sigue usable cuando hay >3 mecánicos o trabajos.

Antes de aprobar

  1. pnpm db:migrate en staging y verificar que no hay errores ni datos huérfanos.
  2. Correr pnpm test:bot y pnpm test:envio (deben pasar sin credenciales).
  3. Probar webhook con número de prueba de Meta: alta, reserva, cancelación, reintento de Meta (mismo wamid no duplica turno).
  4. Verificar que el panel de jefe crea/edita/borra trabajos y que el cliente solo ve los del jefe elegido + globales.
  5. Confirmar que WHATSAPP_APP_SECRET, WHATSAPP_VERIFY_TOKEN, WHATSAPP_TOKEN, WHATSAPP_PHONE_NUMBER_ID están en .env de prod antes de deploy.

#ai-review-summary

**Qué cambia** Se agrega un bot de reservas por WhatsApp (Meta Cloud API) que permite al cliente dar de alta, elegir mecánico, trabajo y horario, y confirmar el turno dentro de la ventana de 24 hs sin plantillas. Incluye: nuevos modelos `ConversacionWhatsapp` (estado de charla) y `MensajeWhatsappProcesado` (deduplicación por `wamid`), cambios de esquema (`Cliente.email` opcional, `telefonoNorm` único, `TipoServicio.jefeMecanicoId` nullable para trabajos globales, `Turno.autoId`/`kilometros` opcionales), endpoints de webhook (GET verificación, POST mensajes con firma HMAC y lock por jefe), máquina de estados en `whatsapp-bot.ts`, cliente Meta en `whatsapp.ts` con reintentos y truncado a límites de la API, creación de turnos centralizada con `pg_advisory_xact_lock`, tests sin BD ni credenciales (`test:bot`, `test:envio`), y ajustes en el front (flujo cliente: jefe → trabajo; pestaña "Trabajos" en panel de jefe). **Riesgos** - **Migración de BD**: varios `ALTER` (nullable, unique, nuevas tablas). Verificar que no rompe datos existentes y que el `unique` en `telefonoNorm` no choca con duplicados previos. - **Webhook sin `WHATSAPP_APP_SECRET`**: rechaza todo con 403 (a propósito), pero si falta en prod el bot queda mudo sin error visible. - **Lock asesor (`pg_advisory_xact_lock`)**: serializa por `jefeMecanicoId`; bajo carga alta puede contender. Revisar que no haya deadlocks y que el `timeout` de transacción sea razonable. - **Idempotencia**: `MensajeWhatsappProcesado` usa PK en `wamid`; limpieza diaria best-effort. Si el índice único falla por duplicados previos (improbable), el webhook tiraría excepción. - **Campos ahora opcionales**: código viejo que asuma `Turno.autoId` o `kilometros` requeridos puede romper (p. ej. exportaciones a Sheets). - **Límites de Meta**: botones máx 3, título 20 chars, texto 4096; el bot recorta silenciosamente. Confirmar que el flujo sigue usable cuando hay >3 mecánicos o trabajos. **Antes de aprobar** 1. `pnpm db:migrate` en staging y verificar que no hay errores ni datos huérfanos. 2. Correr `pnpm test:bot` y `pnpm test:envio` (deben pasar sin credenciales). 3. Probar webhook con número de prueba de Meta: alta, reserva, cancelación, reintento de Meta (mismo `wamid` no duplica turno). 4. Verificar que el panel de jefe crea/edita/borra trabajos y que el cliente solo ve los del jefe elegido + globales. 5. Confirmar que `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID` están en `.env` de prod antes de deploy. #ai-review-summary
ai-bot reviewed 2026-08-05 12:04:27 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -14,2 +10,2 @@
telefono String
createdAt DateTime @default(now())
id String @id @default(cuid())
email String? @unique
Collaborator

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
@@ -16,0 +12,4 @@
nombre String
apellido String
telefono String
telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp
Collaborator

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
@@ -48,3 +52,3 @@
id String @id @default(cuid())
clienteId String
autoId String
autoId String?
Collaborator

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
ai-bot reviewed 2026-08-05 12:04:27 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:27 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -42,0 +41,4 @@
nombre String
duracionHoras Float
activo Boolean @default(true)
jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy)
Collaborator

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
@@ -53,3 +57,3 @@
fechaInicio DateTime
fechaFin DateTime
kilometros Int
kilometros Int?
Collaborator

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
@@ -120,0 +127,4 @@
// 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 '+')
Collaborator

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
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -11,3 +11,3 @@
"db:push": "prisma db push",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
Collaborator

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
@@ -13,1 +13,3 @@
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"test:bot": "tsx scripts/test-whatsapp-bot.ts",
"test:envio": "tsx scripts/test-whatsapp-envio.ts"
Collaborator

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
@@ -120,0 +129,4 @@
id String @id @default(cuid())
telefono String @unique // wa_id del cliente (E.164, sin '+')
paso String @default("INICIO") // paso de la máquina de estados
datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)
Collaborator

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
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -12,2 +12,3 @@
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"test:bot": "tsx scripts/test-whatsapp-bot.ts",
Collaborator

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
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -120,0 +141,4 @@
// 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
Collaborator

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
@@ -120,0 +144,4 @@
id String @id // wamid que manda Meta
createdAt DateTime @default(now())
@@index([createdAt])
Collaborator

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
ai-bot reviewed 2026-08-05 12:04:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:57 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -13,0 +13,4 @@
// 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({
Collaborator

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
@@ -17,3 +32,3 @@
});
if (estado === "COMPLETADO") {
if (estado === "COMPLETADO" && turno.auto && turno.kilometros != null) {
Collaborator

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
@@ -27,3 +42,3 @@
nombre: turno.cliente.nombre,
apellido: turno.cliente.apellido,
email: turno.cliente.email,
email: turno.cliente.email ?? "",
Collaborator

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
ai-bot reviewed 2026-08-05 12:04:57 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:04:57 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:03 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -7,2 +10,4 @@
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId");
if (!jefeMecanicoId)
Collaborator

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
@@ -9,0 +18,4 @@
where: { id: jefeMecanicoId },
select: { activo: true },
});
if (!jefe || !jefe.activo)
Collaborator

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
@@ -9,2 +23,3 @@
const servicios = await prisma.tipoServicio.findMany({
where: { activo: true },
where: whereTrabajosDeJefe(jefeMecanicoId),
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:03 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:03 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:07 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -18,6 +19,8 @@ export async function GET(req: NextRequest) {
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:27 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +8,4 @@
* Correr con: npx tsx scripts/test-whatsapp-bot.ts
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
Collaborator

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
@@ -0,0 +68,4 @@
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];
Collaborator

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
@@ -0,0 +85,4 @@
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();
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:27 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:27 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +94,4 @@
Object.assign(r, update);
return { ...r };
}
const nuevo = { ...defaults(), ...where, ...create };
Collaborator

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
@@ -0,0 +140,4 @@
},
// `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) {
Collaborator

$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
@@ -0,0 +181,4 @@
const paso = (tel: string) => convs.find((c) => c.telefono === tel)?.paso;
async function main() {
const { procesarMensaje } = await import("../src/lib/whatsapp-bot");
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:28 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +251,4 @@
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");
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:29 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +52,4 @@
});
if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 });
const enUso = await prisma.turno.count({ where: { tipoServicioId: id } });
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:29 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +18,4 @@
if (data.duracionHoras !== undefined) {
const dur = Number(data.duracionHoras);
if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:29 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +20,4 @@
const dur = Number(data.duracionHoras);
if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
return NextResponse.json(
{ error: "La duración debe ser un número de horas entre 0 y 24" },
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:30 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +58,4 @@
where: { id },
data: { activo: false },
});
return NextResponse.json({ ...servicio, desactivado: true });
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:59 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -2,3 +2,3 @@
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { slotEstaDisponible } from "@/lib/scheduling";
import { crearTurno } from "@/lib/turnos";
Collaborator

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
@@ -63,0 +40,4 @@
tipoServicioId,
fechaInicio: new Date(fechaInicio),
autoId,
kilometros: km,
Collaborator

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
@@ -63,3 +44,3 @@
});
return NextResponse.json(turno, { status: 201 });
if (!resultado.ok)
Collaborator

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
ai-bot reviewed 2026-08-05 12:05:59 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:59 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:05:59 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -66,0 +46,4 @@
if (!resultado.ok)
return NextResponse.json({ error: resultado.error }, { status: resultado.status });
return NextResponse.json(resultado.turno, { status: 201 });
Collaborator

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
ai-bot reviewed 2026-08-05 12:06:16 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -82,3 +82,3 @@
<span>{t.cliente.telefono}</span>
<span className="text-gray-400">Auto</span>
<span>{t.auto.marca} {t.auto.modelo}</span>
<span>{t.auto ? `${t.auto.marca} ${t.auto.modelo}` : <em className="text-amber-600">a completar</em>}</span>
Collaborator

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
@@ -87,2 +86,3 @@
<span className="font-mono">{t.auto?.patente ?? "—"}</span>
<span className="text-gray-400">Kilometraje</span>
<span>{t.kilometros.toLocaleString("es-AR")} km</span>
<span>{t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : <em className="text-amber-600">a completar</em>}</span>
Collaborator

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
ai-bot reviewed 2026-08-05 12:06:16 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:06:42 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +22,4 @@
if (!sesion || sesion.role !== "jefe")
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json();
Collaborator

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
@@ -0,0 +26,4 @@
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)
Collaborator

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
@@ -0,0 +32,4 @@
{ status: 400 }
);
const servicio = await prisma.tipoServicio.create({
Collaborator

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
ai-bot reviewed 2026-08-05 12:06:42 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:06:42 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:06:43 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +8,4 @@
if (!sesion || sesion.role !== "jefe")
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({
Collaborator

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
@@ -0,0 +26,4 @@
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)
Collaborator

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
ai-bot reviewed 2026-08-05 12:06:43 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:06:55 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
if (pathname === "/jefe") return <>{children}</>;
const tabs = [
Collaborator

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
@@ -26,1 +31,4 @@
</div>
<nav className="flex gap-1">
{tabs.map((t) => {
const activo = pathname === t.href;
Collaborator

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
@@ -27,0 +35,4 @@
return (
<button
key={t.href}
onClick={() => router.push(t.href)}
Collaborator

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
ai-bot reviewed 2026-08-05 12:06:55 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:06:55 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:06:55 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -27,0 +36,4 @@
<button
key={t.href}
onClick={() => router.push(t.href)}
className={`text-sm px-4 py-2 rounded-lg transition ${
Collaborator

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
ai-bot reviewed 2026-08-05 12:07:08 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +23,4 @@
// 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;
Collaborator

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
@@ -0,0 +36,4 @@
}
/** Borra los ids viejos como mucho una vez por día. Best-effort: si falla, no importa. */
async function limpiarProcesadosViejos(): Promise<void> {
Collaborator

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
@@ -0,0 +73,4 @@
let body: PayloadWebhook;
try {
body = JSON.parse(cuerpoCrudo) as PayloadWebhook;
Collaborator

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
ai-bot reviewed 2026-08-05 12:07:08 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:07:08 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:07:08 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +66,4 @@
// 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"))) {
Collaborator

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
@@ -0,0 +86,4 @@
const messageId = mensaje.id;
if (!telefono || !messageId || (await yaProcesado(messageId))) continue;
void marcarLeido(messageId).catch(() => {});
Collaborator

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
@@ -0,0 +102,4 @@
const respuestas = await procesarMensaje(telefono, texto);
await enviarRespuestas(telefono, respuestas);
}
} catch (e) {
Collaborator

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
ai-bot reviewed 2026-08-05 12:07:08 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:07:08 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:07:09 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +25,4 @@
const DIAS_RETENCION = 7;
let ultimaLimpieza = 0;
async function yaProcesado(id: string): Promise<boolean> {
Collaborator

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
ai-bot reviewed 2026-08-05 12:07:41 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -3,0 +6,4 @@
* 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;
Collaborator

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
@@ -55,2 +63,3 @@
fin: Date,
jefeMecanicoId: string
jefeMecanicoId: string,
db: ClienteDb = prisma
Collaborator

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
ai-bot reviewed 2026-08-05 12:07:41 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:08:12 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +8,4 @@
* 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 {
Collaborator

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
@@ -0,0 +11,4 @@
export function whereTrabajosDeJefe(jefeMecanicoId: string): Prisma.TipoServicioWhereInput {
return {
activo: true,
OR: [{ jefeMecanicoId }, { jefeMecanicoId: null }],
Collaborator

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
ai-bot reviewed 2026-08-05 12:08:12 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:08:21 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +35,4 @@
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);
Collaborator

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
@@ -0,0 +41,4 @@
// 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}))`;
Collaborator

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
@@ -0,0 +44,4 @@
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${jefeMecanicoId}))`;
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId, tx);
if (!disponible) return null;
Collaborator

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
ai-bot reviewed 2026-08-05 12:08:21 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:08:21 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:08:22 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +64,4 @@
});
});
if (!turno) return { ok: false, error: "El horario ya no está disponible", status: 409 };
Collaborator

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
ai-bot reviewed 2026-08-05 12:09:17 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +21,4 @@
useEffect(() => { cargar(); }, []);
async function cargar() {
Collaborator

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
@@ -0,0 +36,4 @@
const res = await fetch("/api/jefe/servicios", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nombre, duracionHoras: duracion }),
Collaborator

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
ai-bot reviewed 2026-08-05 12:09:17 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +64,4 @@
const res = await fetch(`/api/jefe/servicios/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duracionHoras: valor }),
Collaborator

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
ai-bot reviewed 2026-08-05 12:09:17 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:09:17 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +49,4 @@
cargar();
}
async function toggleActivo(s: Servicio) {
Collaborator

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
ai-bot reviewed 2026-08-05 12:09:18 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +19,4 @@
const [error, setError] = useState<string | null>(null);
const [guardando, setGuardando] = useState(false);
useEffect(() => { cargar(); }, []);
Collaborator

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
@@ -0,0 +74,4 @@
cargar();
}
async function eliminar(s: Servicio) {
Collaborator

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
ai-bot reviewed 2026-08-05 12:09:18 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:09:18 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +102,4 @@
/>
<Input
label="Duración (horas)"
type="text"
Collaborator

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
ai-bot reviewed 2026-08-05 12:09:18 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +139,4 @@
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="text-sm" onClick={() => editarDuracion(s)}>
Collaborator

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
ai-bot reviewed 2026-08-05 12:10:14 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +86,4 @@
};
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) }));
Collaborator

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
@@ -0,0 +114,4 @@
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));
Collaborator

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
@@ -0,0 +132,4 @@
datos: DatosConv,
offset: number
): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const slots = await obtenerSlotsDisponibles(datos.jefeMecanicoId!, datos.duracionHoras ?? 1, 30, 200);
Collaborator

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
ai-bot reviewed 2026-08-05 12:10:14 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:10:14 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:10:14 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +148,4 @@
const opciones: OpcionMenu[] = pagina.map((s, i) => ({
letra: LETRAS[i] ?? String(i + 1),
id: s.fechaInicio.toISOString(),
Collaborator

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
@@ -0,0 +208,4 @@
}
// Comando global: cancelar
if (NO.includes(lower) && paso !== "CONFIRMAR" && paso !== "INICIO") {
Collaborator

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
ai-bot reviewed 2026-08-05 12:10:14 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +166,4 @@
// ── Persistencia ──
async function guardar(
convId: string,
Collaborator

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
ai-bot reviewed 2026-08-05 12:10:14 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:10:15 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +216,4 @@
switch (paso) {
case "INICIO": {
const cliente = await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });
Collaborator

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
ai-bot reviewed 2026-08-05 12:10:15 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +239,4 @@
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()) {
Collaborator

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
@@ -0,0 +276,4 @@
{ 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({
Collaborator

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
ai-bot reviewed 2026-08-05 12:10:15 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:10:16 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +341,4 @@
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({
Collaborator

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
ai-bot reviewed 2026-08-05 12:11:45 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +52,4 @@
return new Promise((r) => setTimeout(r, ms));
}
async function postMensaje(payload: Record<string, unknown>): Promise<void> {
Collaborator

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
@@ -0,0 +73,4 @@
let status: number;
let detalle = "";
try {
const res = await fetch(url, init);
Collaborator

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
ai-bot reviewed 2026-08-05 12:11:46 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +66,4 @@
Authorization: `Bearer ${cred.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),
Collaborator

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
ai-bot reviewed 2026-08-05 12:11:46 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:11:46 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +133,4 @@
}
/** 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> {
Collaborator

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
ai-bot reviewed 2026-08-05 12:11:46 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +141,4 @@
}
/** Marca el mensaje como leído (los tildes azules). Best-effort: si falla, no importa. */
export async function marcarLeido(messageId: string): Promise<void> {
Collaborator

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
@@ -0,0 +169,4 @@
}
/** Saca el texto de un mensaje según su tipo. `null` = tipo que el bot no maneja. */
export function textoDeMensaje(mensaje: MensajeMeta): string | null {
Collaborator

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
ai-bot reviewed 2026-08-05 12:11:46 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
ai-bot reviewed 2026-08-05 12:11:46 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +27,4 @@
}
/** Valida la firma X-Hub-Signature-256 del webhook contra el cuerpo crudo. */
export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): boolean {
Collaborator

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
ai-bot reviewed 2026-08-05 12:11:47 -03:00
ai-bot left a comment
Collaborator

Inline review

Inline review
@@ -0,0 +100,4 @@
await postMensaje({
to: telefono,
type: "text",
text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },
Collaborator

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
jpbeldomenico added 1 commit 2026-08-05 12:21:08 -03:00
fix(jefe): mensaje de duracion acorde a lo que valida
Revisión con IA / ai-review (pull_request) Successful in 31s
2df679777c
La validacion rechaza 0 pero el mensaje decia "entre 0 y 24". Ojo que
"entre 1 y 24" habria sido igual de incorrecto: duracionHoras es Float y la
UI muestra las duraciones de menos de una hora en minutos, asi que 0,5 (media
hora) es un valor valido y esperado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jpbeldomenico merged commit e85635f272 into main 2026-08-05 12:24:55 -03:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jpbeldomenico/taller_automotriz#4