feat: bot de reservas por WhatsApp (F0) y flujo web jefe -> trabajo #4
Reference in New Issue
Block a user
Delete Branch "feat/whatsapp-bot"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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: traduceRespuestaBota payloads de la Graph API (texto einteractive/button, respetando los limites de 3 botones / 20 caracteres de titulo / 4096 de texto), aplana el payload entrante del webhook y valida la firmaX-Hub-Signature-256en 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 confetchstubbeado, 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
.envde 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 (jefeMecanicoIden null, los carga el admin). Esto evita romper produccion, donde hoy todos los servicios son globales./api/cliente/serviciosahora exigejefeMecanicoIdy aplica esa regla./api/cliente/slotsycrearTurnorechazan 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.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,lintynext 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-lossy esta rama cambia el schema (telefonoNormunico,Turno.autoIdykilometrosopcionales). Arranca en null, asi que es seguro, pero conviene backup de la base primero.🤖 Generated with Claude Code
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>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) yMensajeWhatsappProcesado(deduplicación porwamid), cambios de esquema (Cliente.emailopcional,telefonoNormúnico,TipoServicio.jefeMecanicoIdnullable para trabajos globales,Turno.autoId/kilometrosopcionales), endpoints de webhook (GET verificación, POST mensajes con firma HMAC y lock por jefe), máquina de estados enwhatsapp-bot.ts, cliente Meta enwhatsapp.tscon reintentos y truncado a límites de la API, creación de turnos centralizada conpg_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
ALTER(nullable, unique, nuevas tablas). Verificar que no rompe datos existentes y que eluniqueentelefonoNormno choca con duplicados previos.WHATSAPP_APP_SECRET: rechaza todo con 403 (a propósito), pero si falta en prod el bot queda mudo sin error visible.pg_advisory_xact_lock): serializa porjefeMecanicoId; bajo carga alta puede contender. Revisar que no haya deadlocks y que eltimeoutde transacción sea razonable.MensajeWhatsappProcesadousa PK enwamid; limpieza diaria best-effort. Si el índice único falla por duplicados previos (improbable), el webhook tiraría excepción.Turno.autoIdokilometrosrequeridos puede romper (p. ej. exportaciones a Sheets).Antes de aprobar
pnpm db:migrateen staging y verificar que no hay errores ni datos huérfanos.pnpm test:botypnpm test:envio(deben pasar sin credenciales).wamidno duplica turno).WHATSAPP_APP_SECRET,WHATSAPP_VERIFY_TOKEN,WHATSAPP_TOKEN,WHATSAPP_PHONE_NUMBER_IDestán en.envde prod antes de deploy.#ai-review-summary
Inline review
@@ -14,2 +10,2 @@telefono StringcreatedAt DateTime @default(now())id String @id @default(cuid())email String? @uniqueMaking 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 Stringapellido Stringtelefono StringtelefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApptelefonoNorm 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 StringautoId StringautoId String?autoId is now optional but Turno still requires tipoServicioId and jefeMecanicoId. Ensure business logic handles appointments without a vehicle correctly.
#ai-review-inline
Inline review
Inline review
Inline review
@@ -42,0 +41,4 @@nombre StringduracionHoras Floatactivo Boolean @default(true)jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy)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 DateTimefechaFin DateTimekilometros Intkilometros Int?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 '+')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
Inline review
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",JSON does not support comments; '# added' will cause parsing errors.
#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"JSON does not support comments; '# added' will cause parsing errors.
#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 estadosdatos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)datos field uses Json with default '{}'. Consider defining a TypeScript interface for the expected JSON structure to improve type safety.
#ai-review-inline
Inline review
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",JSON does not support comments; '# added' will cause parsing errors.
#ai-review-inline
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 MetaMensajeWhatsappProcesado 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 MetacreatedAt DateTime @default(now())@@index([createdAt])Index on createdAt for MensajeWhatsappProcesado is good for cleanup queries. Consider adding a TTL/index cleanup strategy documentation.
#ai-review-inline
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({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) {Redundant check: validation at lines 15-26 already guarantees autoId and kilometros exist when reaching this line.
#ai-review-inline
@@ -27,3 +42,3 @@nombre: turno.cliente.nombre,apellido: turno.cliente.apellido,email: turno.cliente.email,email: turno.cliente.email ?? "",Good: nullish coalescing handles missing email gracefully for sheet export.
#ai-review-inline
Inline review
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)Add validation for jefeMecanicoId format (e.g., UUID) to prevent invalid queries.
#ai-review-inline
@@ -9,0 +18,4 @@where: { id: jefeMecanicoId },select: { activo: true },});if (!jefe || !jefe.activo)Consider different error codes: 404 for not found, 403 for inactive jefe.
#ai-review-inline
@@ -9,2 +23,3 @@const servicios = await prisma.tipoServicio.findMany({where: { activo: true },where: whereTrabajosDeJefe(jefeMecanicoId),Verify that whereTrabajosDeJefe returns correct where clause for TipoServicio model, not Trabajo model.
#ai-review-inline
Inline review
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))Consider using 403 Forbidden instead of 400 Bad Request since the mechanic exists but doesn't perform this service type.
#ai-review-inline
Inline review
@@ -0,0 +8,4 @@* Correr con: npx tsx scripts/test-whatsapp-bot.ts*//* eslint-disable @typescript-eslint/no-explicit-any */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];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();Direct mutation of row.updatedAt affects the stored object; consider creating a new object to avoid side effects.
#ai-review-inline
Inline review
Inline review
Inline review
@@ -0,0 +94,4 @@Object.assign(r, update);return { ...r };}const nuevo = { ...defaults(), ...where, ...create };Spread order (defaults, where, create) may cause where clauses (e.g., id) to override create data; create should take precedence.
#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) {$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");Dynamic import after global prisma assignment is correct but fragile; consider a setup function to make intent clearer.
#ai-review-inline
Inline review
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");Importing crearTurno inside main() after tests start is unconventional; move to top with other imports (after global prisma set).
#ai-review-inline
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 } });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
Inline review
@@ -0,0 +18,4 @@if (data.duracionHoras !== undefined) {const dur = Number(data.duracionHoras);if (!Number.isFinite(dur) || dur <= 0 || dur > 24)Duration validation rejects 0 but error message says 'entre 0 y 24'. Should be 'entre 1 y 24' or allow 0 with
dur < 0.#ai-review-inline
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" },Error message inconsistent with validation logic (allows 0 vs rejects 0).
#ai-review-inline
Inline review
@@ -0,0 +58,4 @@where: { id },data: { activo: false },});return NextResponse.json({ ...servicio, desactivado: true });Response includes custom
desactivadoproperty not in Prisma model. Ensure frontend handles this.#ai-review-inline
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";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,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)Confirm resultado.status returns appropriate HTTP codes (e.g., 404, 409) for each error case.
#ai-review-inline
Inline review
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 });Ensure resultado.turno includes the same include relations (auto, tipoServicio, jefeMecanico) as before for client compatibility.
#ai-review-inline
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>Inconsistent return types in ternary: string vs React element. Use a string for both branches or wrap both in a fragment.
#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>Inconsistent return types in ternary: string vs React element. Use a string for both branches.
#ai-review-inline
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();req.json() throws on invalid JSON, resulting in 500. Wrap in try-catch to return 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)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.
#ai-review-inline
@@ -0,0 +32,4 @@{ status: 400 });const servicio = await prisma.tipoServicio.create({Prisma create can throw (e.g., unique constraint). Add try-catch to return 400/500 appropriately.
#ai-review-inline
Inline review
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({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)Magic number 24 for max hours. Define as constant for maintainability.
#ai-review-inline
Inline review
Inline review
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })if (pathname === "/jefe") return <>{children}</>;const tabs = [Consider moving the tabs array outside the component to avoid recreating it on every render.
#ai-review-inline
@@ -26,1 +31,4 @@</div><nav className="flex gap-1">{tabs.map((t) => {const activo = pathname === t.href;Exact pathname match may not highlight parent tab for nested routes (e.g., /jefe/dashboard/123). Consider using pathname.startsWith(t.href).
#ai-review-inline
@@ -27,0 +35,4 @@return (<buttonkey={t.href}onClick={() => router.push(t.href)}Use Next.js Link component for navigation instead of button with router.push for better accessibility, SEO, and prefetching.
#ai-review-inline
Inline review
Inline review
Inline review
@@ -27,0 +36,4 @@<buttonkey={t.href}onClick={() => router.push(t.href)}className={`text-sm px-4 py-2 rounded-lg transition ${ClassName template literal is hard to read. Extract conditional classes into a variable or use a helper like clsx.
#ai-review-inline
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;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> {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;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
Inline review
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"))) {Header 'x-hub-signature-256' may be undefined; firmaValida should handle missing header explicitly or check here before calling.
#ai-review-inline
@@ -0,0 +86,4 @@const messageId = mensaje.id;if (!telefono || !messageId || (await yaProcesado(messageId))) continue;void marcarLeido(messageId).catch(() => {});Fire-and-forget with empty catch hides potential issues marking messages as read; at least log the error for observability.
#ai-review-inline
@@ -0,0 +102,4 @@const respuestas = await procesarMensaje(telefono, texto);await enviarRespuestas(telefono, respuestas);}} catch (e) {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
Inline review
Inline review
Inline review
@@ -0,0 +25,4 @@const DIAS_RETENCION = 7;let ultimaLimpieza = 0;async function yaProcesado(id: string): Promise<boolean> {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
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;Consider exporting ClienteDb type if it will be reused in other modules; otherwise, keep it private with a leading underscore or JSDoc @internal.
#ai-review-inline
@@ -55,2 +63,3 @@fin: Date,jefeMecanicoId: stringjefeMecanicoId: string,db: ClienteDb = prismaThe default parameter
db = prismaworks because PrismaClient satisfies TransactionClient, but add a comment to clarify this intentional covariance for future maintainers.#ai-review-inline
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 {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 }],Consider using explicit property syntax for clarity: { jefeMecanicoId: jefeMecanicoId }
#ai-review-inline
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);servicio.duracionHoras might be null/undefined; add a guard or default to avoid NaN in fechaFin.
#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}))`;hashtext() returns a 32-bit integer; advisory lock collisions are possible under high concurrency. Consider using a 64-bit hash or a composite key (e.g., hashtext(jefeMecanicoId || 'turno')).
#ai-review-inline
@@ -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;Returning null from transaction callback to signal conflict is unconventional; consider throwing a specific error or returning a result object for clarity.
#ai-review-inline
Inline review
Inline review
Inline review
@@ -0,0 +64,4 @@});});if (!turno) return { ok: false, error: "El horario ya no está disponible", status: 409 };The null check catches the conflict but masks other potential null returns; narrow the check to the specific error thrown above.
#ai-review-inline
Inline review
@@ -0,0 +21,4 @@useEffect(() => { cargar(); }, []);async function cargar() {cargar() doesn't handle network errors or non-ok responses; add error handling.
#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 }),duracion is a string but duracionHoras expects a number; parse it before sending.
#ai-review-inline
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 }),valor from prompt is a string; parse to number for duracionHoras.
#ai-review-inline
Inline review
Inline review
@@ -0,0 +49,4 @@cargar();}async function toggleActivo(s: Servicio) {toggleActivo doesn't handle errors; UI may become out of sync if request fails.
#ai-review-inline
Inline review
@@ -0,0 +19,4 @@const [error, setError] = useState<string | null>(null);const [guardando, setGuardando] = useState(false);useEffect(() => { cargar(); }, []);useEffect depends on cargar which is recreated each render; wrap cargar in useCallback or add to deps.
#ai-review-inline
@@ -0,0 +74,4 @@cargar();}async function eliminar(s: Servicio) {eliminar doesn't check res.ok before parsing; may miss errors.
#ai-review-inline
Inline review
Inline review
@@ -0,0 +102,4 @@/><Inputlabel="Duración (horas)"type="text"Input uses type="text" with inputMode="decimal"; consider type="number" for better validation.
#ai-review-inline
Inline review
@@ -0,0 +139,4 @@</p></div><div className="flex gap-2"><Button variant="secondary" className="text-sm" onClick={() => editarDuracion(s)}>Action buttons lack loading/disabled states; users may trigger multiple requests.
#ai-review-inline
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) }));Buttons only show first 3 mechanics while text lists all; users can't select mechanics beyond the first 3 via buttons.
#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 opcionesservicios.forEach((s, i) => (opciones[i].id = s.id));Mutating opciones array after listaConLetras returns it creates hidden side effect; assign ids during mapping instead.
#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);Magic numbers 30 and 200 for days ahead and max slots; extract to named constants for clarity.
#ai-review-inline
Inline review
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(),Using ISO string as option id but elegirOpcion lowercases it; ISO strings contain uppercase letters (T, Z) causing mismatch when user types the letter.
#ai-review-inline
@@ -0,0 +208,4 @@}// Comando global: cancelarif (NO.includes(lower) && paso !== "CONFIRMAR" && paso !== "INICIO") {Global 'no' cancels even during VERIFICAR_EMAIL step where user might be answering 'no' to a different question; restrict cancel to specific steps.
#ai-review-inline
Inline review
@@ -0,0 +166,4 @@// ── Persistencia ──async function guardar(convId: string,Unsafe cast 'as unknown as Prisma.InputJsonValue' bypasses type safety; use proper JSON serialization or Prisma's JsonValue type.
#ai-review-inline
Inline review
Inline review
@@ -0,0 +216,4 @@switch (paso) {case "INICIO": {const cliente = await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });Using findFirst for telefonoNorm which should be unique; use findUnique with proper unique index for clarity and performance.
#ai-review-inline
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()) {Comparing lowercased input with lowercased email is correct but redundant since 'lower' is already lowercased; simplify.
#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({Setting both telefono and telefonoNorm to same normalized value; ensure schema requires both or remove redundancy.
#ai-review-inline
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({Non-null assertion on conv.clienteId could crash if clientId is null (e.g., new client flow bug); add guard or handle gracefully.
#ai-review-inline
Inline review
@@ -0,0 +52,4 @@return new Promise((r) => setTimeout(r, ms));}async function postMensaje(payload: Record<string, unknown>): Promise<void> {postMensaje fails silently when credentials are missing; callers have no way to know the message wasn't sent.
#ai-review-inline
@@ -0,0 +73,4 @@let status: number;let detalle = "";try {const res = await fetch(url, init);fetch has no timeout; a stalled request can hang indefinitely.
#ai-review-inline
Inline review
@@ -0,0 +66,4 @@Authorization: `Bearer ${cred.token}`,"Content-Type": "application/json",},body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),Spread operator allows payload to overwrite messaging_product; use explicit object to avoid accidents.
#ai-review-inline
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> {enviarRespuestas ignores individual send failures; a failed message won't stop the loop but errors are lost.
#ai-review-inline
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> {marcarLeido reuses postMensaje which is semantically for sending messages; consider a dedicated low-level POST helper.
#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 {textoDeMensaje returns button id for interactive but button text for legacy button type; inconsistent for callers.
#ai-review-inline
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 {firmaValida returns false when WHATSAPP_APP_SECRET is missing, which may hide configuration errors in production.
#ai-review-inline
Inline review
@@ -0,0 +100,4 @@await postMensaje({to: telefono,type: "text",text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },preview_url hardcoded to false; URLs in text messages won't show previews. Make configurable if needed.
#ai-review-inline