23 Commits

Author SHA1 Message Date
Beldor e0d6035485 fix(bot): saludar como Taller Beldor
Para que el saludo coincida con el Display Name de la cuenta de
WhatsApp Business y con el nombre del sitio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 17:18:08 -03:00
Beldor d917618e67 fix(sitio): nombrar el sitio Taller Beldor
Meta rechaza el alta de WhatsApp (#2494070) cuando el nombre declarado
del negocio no coincide con el del sitio que lo respalda. El negocio es
Taller Beldor y el sitio decia Taller MG.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 17:17:06 -03:00
jpbeldomenico 8fcd0fbf77 Merge pull request (#8): pasar las credenciales de Meta al contenedor
Deploy / deploy (push) Successful in 4m47s
2026-08-07 17:09:43 -03:00
Beldor 31bd0ad0bd fix(whatsapp): pasar las credenciales de Meta al contenedor
Revisión con IA / ai-review (pull_request) Successful in 47s
docker-compose.yml no interpolaba ninguna WHATSAPP_*, asi que las cuatro
variables cargadas en el .env de produccion no llegaban a la app: los
envios cortaban con "falta WHATSAPP_TOKEN" y el webhook devolvia 403 al
no tener APP_SECRET con que validar la firma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:07:21 -03:00
jpbeldomenico 2bdf76ab63 Merge pull request (#7): comprobar el rol de la sesion en todas las rutas
Deploy / deploy (push) Successful in 3m5s
2026-08-05 17:02:50 -03:00
Beldor f4df90ac1e fix(auth): comprobar el rol de la sesion en todas las rutas
Revisión con IA / ai-review (pull_request) Successful in 1m24s
Las tres sesiones (admin, cliente, jefe) se firman con el mismo secreto, asi que
verificar solo la firma alcanzaba para que un token de un rol pasara por la
cookie de otro. Verificado antes del cambio: un token de admin en
cliente_session daba 200 en /api/cliente/autos, y uno de jefe cargaba
/cliente/dashboard. Despues: 401 y 307.

- Las 26 llamadas a verificarSesion pasan el rol esperado.
- El middleware, que hacia su propio jwtVerify sin mirar el rol, ahora tambien
  lo comprueba.
- Se sacan las comprobaciones sueltas de rol en las rutas de jefe, que quedaron
  redundantes.

Matriz probada contra la app: los tres roles siguen entrando en su area (200) y
ninguno entra en la de los otros, ni por API (401) ni por pagina (307).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:45:33 -03:00
jpbeldomenico 97f9ccec28 Merge pull request (#6): datos de contacto del cliente
Deploy / deploy (push) Successful in 3m4s
2026-08-05 16:09:45 -03:00
Beldor ce7063514c fix(perfil): detalles senalados en la revision
Revisión con IA / ai-review (pull_request) Successful in 24s
- verificarSesion acepta el rol esperado. Las tres sesiones (admin, cliente,
  jefe) se firman con el mismo secreto, asi que un token de jefe puesto en la
  cookie cliente_session pasaba la verificacion de firma; ahora el rol se
  comprueba. Verificado: con un JWT de jefe, /api/cliente/perfil da 401.
- El update traduce el P2002 de los indices unicos a un 409 legible, por si otra
  request toma el mismo email o telefono entre la comprobacion y el update.
- La pantalla ya no queda en "Cargando..." para siempre si falla el pedido.
- Si la sesion vence mientras edita, el PATCH manda a entrar de nuevo en vez de
  mostrar un error suelto.

Queda como mejora aparte pasarle el rol esperado al resto de las rutas del area
del cliente, que hoy solo validan la firma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:09:40 -03:00
Beldor 62dab5e025 feat(cliente): ver y modificar los datos de contacto
Revisión con IA / ai-review (pull_request) Successful in 2m18s
El cliente no tenia forma de corregir su email ni su telefono despues del alta.
Nueva pantalla /cliente/perfil ("Mis datos", enlazada desde el dashboard) que
muestra nombre, email y telefono, y permite editarlos.

- GET/PATCH /api/cliente/perfil, ambos con la sesion del cliente.
- El telefono usa el mismo campo con el +54 9 fijo que el registro, y se guarda
  normalizado en telefonoNorm para que el bot lo siga reconociendo.
- aLocalArgentino() saca el prefijo para mostrar: un cliente dado de alta por
  WhatsApp tiene el numero guardado como 5491141984844 y en el formulario tiene
  que verse 1141984844.
- Email y telefono se validan unicos contra los demas clientes (409), no contra
  uno mismo. Si cambia el email se avisa que con ese va a entrar de ahora en mas,
  porque es la identidad del magic link.
- El middleware protege /cliente/perfil como el resto del area del cliente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:54:38 -03:00
jpbeldomenico 2baa7952fa Merge pull request (#5): telefono en el formato que usa WhatsApp
Deploy / deploy (push) Successful in 3m0s
2026-08-05 15:03:21 -03:00
Beldor 9e93808dc1 fix: detalles del formato de telefono senalados en la revision
Revisión con IA / ai-review (pull_request) Successful in 25s
- esLocalArgentinoValido tambien rechaza los que arrancan con 15: ninguna
  caracteristica argentina empieza asi, es el viejo prefijo de celular.
- El formulario ya no reta "faltan digitos" mientras se esta tipeando; ese
  aviso espera a que el campo pierda el foco. Los errores seguros (0 o 15
  adelante) se siguen mostrando al instante.
- chat-bot.ts normalizaba el telefono para conversar pero no para /reset, asi
  que el reset no encontraba la conversacion y no borraba nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:02:59 -03:00
Beldor ffa32a113d fix(registro): guardar el telefono en el formato que usa WhatsApp
Revisión con IA / ai-review (pull_request) Successful in 2m12s
Un cliente registrado por la web no lo reconocia el bot: el formulario dejaba
escribir el numero en cualquier formato (1141984844, 01157260394) mientras que
Meta manda el wa_id 5491141984844, asi que la busqueda por telefonoNorm no
encontraba nada, mandaba al alta y ahi chocaba contra el email ya registrado.
Quedaba en un callejon sin salida.

Ahora el campo muestra "+54 9" fijo y grisado y el cliente completa solo la
caracteristica (sin el 0) y el abonado (sin el 15), 10 digitos. El backend
valida ese formato y guarda telefonoNorm ya como lo espera el bot. Se rechaza
tambien un telefono repetido, que antes reventaba con el indice unico.

Se agrega scripts/chat-bot.ts, que es como se encontro esto: permite conversar
con el bot desde la consola contra cualquier base, sin Meta ni credenciales.
Corriendolo contra una copia de la base de produccion se ve el ciclo completo:
alta por web -> el bot lo reconoce por telefono -> pide el email -> reserva.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:56:15 -03:00
jpbeldomenico e85635f272 Merge pull request (#4): bot de reservas por WhatsApp y flujo web jefe -> trabajo
Deploy / deploy (push) Successful in 4m52s
2026-08-05 12:24:53 -03:00
Beldor 2df679777c fix(jefe): mensaje de duracion acorde a lo que valida
Revisión con IA / ai-review (pull_request) Successful in 31s
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>
2026-08-05 12:21:02 -03:00
Beldor 91112862a5 fix: los cuatro hallazgos reales de la revision con IA
Revisión con IA / ai-review (pull_request) Successful in 29s
- 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>
2026-08-05 11:21:35 -03:00
Beldor 44aeb58e77 feat(cliente): invertir el flujo web a jefe -> trabajo
Revisión con IA / ai-review (pull_request) Failing after 2m12s
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>
2026-08-05 09:55:47 -03:00
jpbeldomenico 15d6e8a2ee feat(whatsapp): webhook + envio por Graph API (F0)
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>
2026-08-05 09:55:47 -03:00
Beldor 0046b6c3cf feat(whatsapp): máquina de conversación del bot de reservas (F2) + test
- 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>
2026-08-05 09:55:47 -03:00
Beldor c53e5bb5aa feat(whatsapp): schema del bot de reservas + panel de trabajos por jefe (F1+F3)
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>
2026-08-05 09:55:47 -03:00
jpbeldomenico dd7f73453e Merge pull request 'ci: reintentar la revision con IA cuando falla el proveedor' (#3) from fix/ai-review-reintentos into main
Deploy / deploy (push) Successful in 3m22s
2026-08-03 22:50:20 -03:00
jpbeldomenico a312815140 fix(ci): no dar por fallida una revision por el texto de su comentario
Revisión con IA / ai-review (pull_request) Successful in 1m59s
El guard buscaba "Error: " en el log, pero el log tambien trae lo que escribe
el modelo. En inventario#3 la revision salio bien y comento justamente que ese
patron era demasiado amplio: el comentario quedo impreso, matcheo el patron y
el job quedo en rojo. Lo encontro la propia revision con IA.

Ahora se miran las marcas de nivel que imprime la herramienta (| ERROR | y
| CRITICAL |), que el modelo no escribe, y el traceback al principio de linea.
Verificado contra los logs reales guardados: detecta la falla por el 502 de
OpenRouter y deja pasar las tres revisiones que salieron bien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:26:21 -03:00
jpbeldomenico a7bb038b6b fix(ci): reintentar la revision con IA cuando falla el proveedor
Revisión con IA / ai-review (pull_request) Successful in 44s
El 03/08/2026 OpenRouter devolvio 502 con el modelo gratuito y el pull
request se mergeo sin revisar. Ahora revisar() reintenta hasta 3 veces con
espera creciente (30s, 60s), pero solo ante las marcas que deja una falla
al pedirle la respuesta al modelo: un 401 o un modelo que ya no existe
siguen cortando en el primer intento, porque esperar no los arregla.

De paso se saca la tuberia a tee: el codigo de salida que se evaluaba era
el de tee, siempre 0, asi que esa mitad del guard nunca hizo nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:10:32 -03:00
jpbeldomenico d74c97e532 Merge pull request 'ci: construir antes de bajar el servicio' (#2) from fix/deploy-robusto into main
Deploy / deploy (push) Successful in 4m46s
2026-08-03 20:04:35 -03:00
46 changed files with 2298 additions and 167 deletions
+10
View File
@@ -4,3 +4,13 @@ NEXTAUTH_URL="http://localhost:3000"
GMAIL_USER="tu-email@gmail.com"
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
MAGIC_LINK_BASE_URL="http://localhost:3000"
# Bot de reservas por WhatsApp (Meta Cloud API).
# PHONE_NUMBER_ID: panel de Meta -> WhatsApp -> API Setup (es un id numérico, NO el teléfono).
# TOKEN: token permanente de un System User con whatsapp_business_messaging + _management.
# VERIFY_TOKEN: string inventado por vos; el mismo va acá y en el panel al dar de alta el webhook.
# APP_SECRET: App -> Settings -> Basic -> App Secret. Sin esto el webhook rechaza todo.
WHATSAPP_PHONE_NUMBER_ID=""
WHATSAPP_TOKEN=""
WHATSAPP_VERIFY_TOKEN=""
WHATSAPP_APP_SECRET=""
+49 -8
View File
@@ -179,15 +179,56 @@ jobs:
# comentarios: imprime el traceback y saluda con
# "AI review completed successfully!". Sin esto el job quedaria en
# verde sin haber revisado nada, que es peor que fallar.
#
# Cuidado con QUE se busca: el log tambien trae el texto de los
# comentarios que escribe el modelo, y ese texto puede decir
# cualquier cosa. El patron 'Error: ' que habia antes se disparo con
# el comentario de la propia revision (inventario#3) y marco como
# fallida una revision que habia salido bien. Por eso ahora se miran
# las marcas de nivel que imprime la herramienta, que el modelo no
# escribe, y el traceback al principio de linea.
ERRORES='\| (ERROR|CRITICAL) \||^Traceback \(most recent call last\)'
# El plan gratuito de OpenRouter devuelve 502 cuando los proveedores
# del modelo estan saturados; pasó el 03/08/2026 y el pull request se
# mergeo sin revisar. Eso se reintenta, pero un 401 o un modelo que ya
# no existe no se arreglan esperando: por eso solo reintentamos con
# las marcas que deja una falla al pedirle la respuesta al modelo.
# Todas ocurren antes de publicar nada, asi que el reintento no puede
# dejar comentarios repetidos.
PASAJEROS='OpenRouterChatResponseSchema|LLM returned empty|Upstream|Too Many Requests|timed out'
INTENTOS=3
PRIMERA_ESPERA=30
revisar() {
echo "== ai-review $1"
if ! ai-review "$1" 2>&1 | tee "/tmp/ai-review-$1.log"; then
return 1
fi
if grep -qiE 'Traceback \(most recent call last\)|HTTPClientError|Error: ' "/tmp/ai-review-$1.log"; then
echo "La revisión reportó errores en '$1' pese a terminar sin código de error." >&2
return 1
fi
modo="$1"
registro="/tmp/ai-review-${modo}.log"
intento=1
espera="${PRIMERA_ESPERA}"
while : ; do
echo "== ai-review ${modo} (intento ${intento} de ${INTENTOS})"
# Sin tuberia a proposito: con "| tee" el codigo que se evalua es
# el de tee, que siempre da 0, y la falla pasaba desapercibida.
salida=0
ai-review "${modo}" > "${registro}" 2>&1 || salida=$?
cat "${registro}"
if [ "${salida}" -eq 0 ] && ! grep -qE "${ERRORES}" "${registro}"; then
return 0
fi
if [ "${intento}" -ge "${INTENTOS}" ] || ! grep -qE "${PASAJEROS}" "${registro}"; then
echo "La revisión reportó errores en '${modo}' pese a terminar sin código de error." >&2
return 1
fi
echo "Falla pasajera del proveedor; reintento en ${espera}s." >&2
sleep "${espera}"
intento=$((intento + 1))
espera=$((espera * 2))
done
}
# Para depurar la configuración existe "ai-review show-config", pero
+7
View File
@@ -33,6 +33,13 @@ services:
GOOGLE_CLIENT_EMAIL: ${GOOGLE_CLIENT_EMAIL}
GOOGLE_SHEET_ID: ${GOOGLE_SHEET_ID}
GOOGLE_PRIVATE_KEY: ${GOOGLE_PRIVATE_KEY}
# Bot de WhatsApp. Sin estas cuatro el .env de produccion no llega al
# contenedor: los envios avisan que falta el token y el webhook contesta
# 403 porque no puede validar la firma. Ver docs/whatsapp.md.
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID}
WHATSAPP_TOKEN: ${WHATSAPP_TOKEN}
WHATSAPP_VERIFY_TOKEN: ${WHATSAPP_VERIFY_TOKEN}
WHATSAPP_APP_SECRET: ${WHATSAPP_APP_SECRET}
ports:
- "3004:3000"
+53
View File
@@ -0,0 +1,53 @@
# Bot de reservas por WhatsApp
Flujo: el cliente escribe al número del taller → el bot lo identifica por teléfono (verifica
con el email registrado, o lo da de alta) → elige mecánico, trabajo y horario → confirma y se
crea el Turno. Todo pasa dentro de la ventana de 24hs que abre el cliente, así que **no hacen
falta plantillas aprobadas** ni tiene costo.
## Piezas
| Archivo | Qué hace |
|---|---|
| `src/lib/whatsapp-bot.ts` | Máquina de conversación. No sabe nada de Meta: devuelve `RespuestaBot[]` |
| `src/lib/whatsapp.ts` | Traduce a/desde la Graph API de Meta y valida la firma del webhook |
| `src/app/api/whatsapp/webhook/route.ts` | Endpoint: verificación (GET) y mensajes entrantes (POST) |
Tests sin DB ni credenciales: `npm run test:bot` (conversación) y `npm run test:envio` (capa Meta).
## Variables de entorno
```
WHATSAPP_PHONE_NUMBER_ID= # id numérico del número, NO el teléfono
WHATSAPP_TOKEN= # token permanente de System User
WHATSAPP_VERIFY_TOKEN= # string inventado, el mismo acá y en el panel de Meta
WHATSAPP_APP_SECRET= # App -> Settings -> Basic -> App Secret
```
Sin `WHATSAPP_APP_SECRET` el webhook rechaza todo con 403 (es a propósito: sin firma no se
puede confiar en el payload).
## Alta en Meta (una sola vez)
1. Meta Business Suite con la cuenta de Facebook del taller.
2. developers.facebook.com → crear App tipo **Business** → agregar producto **WhatsApp**.
3. **WhatsApp → API Setup**: de ahí salen `PHONE_NUMBER_ID` y un token temporal (24hs).
Para el token definitivo: Business Settings → System Users → crear usuario → Generate Token
con permisos `whatsapp_business_messaging` y `whatsapp_business_management`.
4. **App → Settings → Basic → App Secret** → `WHATSAPP_APP_SECRET`.
5. Cargar las 4 variables en el `.env` de producción (`/home/proyecto/taller_automotriz/.env`,
al lado del `docker-compose.yml`, que es de donde compose las interpola) y levantar de nuevo
con `docker compose up -d`. Un `git push` no alcanza: el `.env` no está en el repo.
6. **WhatsApp → Configuration → Webhook**:
- Callback URL: `https://taller.beldo.duckdns.org/api/whatsapp/webhook`
- Verify token: el mismo `WHATSAPP_VERIFY_TOKEN`
- Suscribir el campo **`messages`** (sin esto no llega nada).
7. Probar con el número de prueba que da Meta gratis, antes de migrar el número real.
Notas:
- El número del taller no puede estar activo en la app de WhatsApp normal/Business: hay que
borrar esa cuenta antes de migrarlo a Cloud API.
- Business Verification se saltea mientras estemos abajo de 1000 usuarios únicos/24hs; solo
hace falta la aprobación del **Display Name**.
- Meta permite 3 botones como máximo. Si hay más de 3 mecánicos activos, salen los 3 primeros
como botón y el resto se elige respondiendo con el número (el bot acepta las dos formas).
+3 -1
View File
@@ -10,7 +10,9 @@
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"test:bot": "tsx scripts/test-whatsapp-bot.ts",
"test:envio": "tsx scripts/test-whatsapp-envio.ts"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
+48 -19
View File
@@ -7,16 +7,18 @@ datasource db {
}
model Cliente {
id String @id @default(cuid())
email String @unique
nombre String
apellido String
telefono String
createdAt DateTime @default(now())
id String @id @default(cuid())
email String? @unique
nombre String
apellido String
telefono String
telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp
createdAt DateTime @default(now())
autos Auto[]
turnos Turno[]
magicTokens MagicToken[]
autos Auto[]
turnos Turno[]
magicTokens MagicToken[]
conversaciones ConversacionWhatsapp[]
}
model Auto {
@@ -35,30 +37,32 @@ model Auto {
}
model TipoServicio {
id String @id @default(cuid())
nombre String
duracionHoras Float
activo Boolean @default(true)
id String @id @default(cuid())
nombre String
duracionHoras Float
activo Boolean @default(true)
jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy)
turnos Turno[]
historial HistorialServicio[]
jefeMecanico JefeMecanico? @relation(fields: [jefeMecanicoId], references: [id])
turnos Turno[]
historial HistorialServicio[]
}
model Turno {
id String @id @default(cuid())
clienteId String
autoId String
autoId String?
tipoServicioId String
jefeMecanicoId String
fechaInicio DateTime
fechaFin DateTime
kilometros Int
kilometros Int?
estado EstadoTurno @default(PENDIENTE)
comentario String?
createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id])
auto Auto @relation(fields: [autoId], references: [id])
auto Auto? @relation(fields: [autoId], references: [id])
tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id])
jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id])
}
@@ -72,7 +76,8 @@ model JefeMecanico {
activo Boolean @default(true)
createdAt DateTime @default(now())
turnos Turno[]
turnos Turno[]
servicios TipoServicio[]
}
model HistorialServicio {
@@ -117,3 +122,27 @@ enum EstadoTurno {
CANCELADO
COMPLETADO
}
// Estado de la conversación del bot de WhatsApp, por número de teléfono.
// El webhook es stateless: acá se guarda en qué paso está y las selecciones parciales.
model ConversacionWhatsapp {
id String @id @default(cuid())
telefono String @unique // wa_id del cliente (E.164, sin '+')
paso String @default("INICIO") // paso de la máquina de estados
datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)
clienteId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cliente Cliente? @relation(fields: [clienteId], references: [id])
}
// Ids de mensajes de WhatsApp ya procesados. Meta reintenta el mismo mensaje si el
// webhook tarda o falla, y sin esto un reintento crea el turno dos veces. Va en la
// base (y no en memoria) para que siga valiendo con más de una instancia.
model MensajeWhatsappProcesado {
id String @id // wamid que manda Meta
createdAt DateTime @default(now())
@@index([createdAt])
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Chatear con el bot de WhatsApp desde la consola, sin Meta.
*
* Habla directo con `procesarMensaje`, que es lo mismo que invoca el webhook, así
* que ejercita la conversación completa contra la base a la que apunte DATABASE_URL
* (turnos incluidos: lo que reserves acá queda guardado).
*
* DATABASE_URL=... npx tsx scripts/chat-bot.ts [telefono]
*
* Acepta entrada interactiva o por tubería (una línea por mensaje).
* Comandos propios: /salir, /reset (borra la conversación de ese teléfono).
*/
import { createInterface } from "readline";
import { prisma } from "../src/lib/prisma";
import { procesarMensaje } from "../src/lib/whatsapp-bot";
import { normalizarTelefono } from "../src/lib/telefono";
// Normalizado igual que en el bot: si no, /reset no encuentra la conversación.
const telefono = normalizarTelefono(process.argv[2] ?? "5493510000000");
function mostrar(resp: { tipo: string; texto: string; botones?: { titulo: string }[] }[]) {
for (const r of resp) {
console.log("\n🤖 " + r.texto.replace(/\n/g, "\n "));
if (r.tipo === "botones" && r.botones)
console.log(" [ " + r.botones.map((b) => b.titulo).join(" ] [ ") + " ]");
}
}
async function main() {
console.log(`Chateando como ${telefono}. /salir para terminar, /reset para empezar de cero.`);
const rl = createInterface({ input: process.stdin, terminal: false });
for await (const linea of rl) {
const texto = linea.trim();
if (texto === "/salir") break;
if (texto === "/reset") {
await prisma.conversacionWhatsapp.deleteMany({ where: { telefono } });
console.log("\n(conversación borrada)");
continue;
}
console.log(`\n👤 ${texto}`);
mostrar(await procesarMensaje(telefono, texto));
}
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+270
View File
@@ -0,0 +1,270 @@
/**
* Test de la máquina de estados del bot de WhatsApp SIN base de datos ni Meta.
*
* Inyecta un Prisma en memoria en `globalThis.prisma` ANTES de importar el bot,
* así el singleton de `src/lib/prisma.ts` lo toma y nunca abre una conexión real.
* De paso ejercita el scheduling y crearTurno reales (usan el mismo Prisma).
*
* Correr con: npx tsx scripts/test-whatsapp-bot.ts
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type Row = Record<string, any>;
let _id = 0;
const nid = (p: string) => `${p}${++_id}`;
function cmp(x: any) {
return x instanceof Date ? x.getTime() : x;
}
function match(row: Row, where: Row): boolean {
for (const [k, v] of Object.entries(where)) {
if (v === undefined) continue;
if (k === "AND") {
if (!(v as Row[]).every((w) => match(row, w))) return false;
continue;
}
if (k === "OR") {
if (!(v as Row[]).some((w) => match(row, w))) return false;
continue;
}
const rv = row[k];
if (v !== null && typeof v === "object" && !(v instanceof Date)) {
if ("in" in v && !(v.in as any[]).includes(rv)) return false;
if ("lt" in v && !(cmp(rv) < cmp(v.lt))) return false;
if ("lte" in v && !(cmp(rv) <= cmp(v.lte))) return false;
if ("gt" in v && !(cmp(rv) > cmp(v.gt))) return false;
if ("gte" in v && !(cmp(rv) >= cmp(v.gte))) return false;
} else if (rv !== v) {
return false;
}
}
return true;
}
function pick(row: Row, select?: Row) {
if (!select) return { ...row };
const o: Row = {};
for (const k of Object.keys(select)) if (select[k]) o[k] = row[k];
return o;
}
// ── Stores en memoria ──
const clientes: Row[] = [];
const jefes: Row[] = [];
const servicios: Row[] = [];
const turnos: Row[] = [];
const convs: Row[] = [];
function modelo(rows: Row[], defaults: () => Row = () => ({})) {
return {
async findFirst({ where }: any = {}) {
const r = rows.find((x) => match(x, where || {}));
return r ? { ...r } : null;
},
async findUnique({ where, select }: any = {}) {
const r = rows.find((x) => match(x, where || {}));
return r ? pick(r, select) : null;
},
async findMany({ where, orderBy, select }: any = {}) {
let rs = rows.filter((x) => match(x, where || {}));
if (orderBy) {
const [f, dir] = Object.entries(orderBy)[0] as [string, string];
rs = [...rs].sort((a, b) => (a[f] > b[f] ? 1 : a[f] < b[f] ? -1 : 0) * (dir === "desc" ? -1 : 1));
}
return rs.map((r) => pick(r, select));
},
async count({ where }: any = {}) {
return rows.filter((x) => match(x, where || {})).length;
},
async create({ data }: any) {
const row = { ...defaults(), ...data };
rows.push(row);
return { ...row };
},
async update({ where, data }: any) {
const r = rows.find((x) => match(x, where || {}));
if (!r) throw new Error("update: fila no encontrada");
Object.assign(r, data);
r.updatedAt = new Date();
return { ...r };
},
async upsert({ where, create, update }: any) {
const r = rows.find((x) => match(x, where || {}));
if (r) {
Object.assign(r, update);
return { ...r };
}
const nuevo = { ...defaults(), ...where, ...create };
rows.push(nuevo);
return { ...nuevo };
},
};
}
const fakePrisma: any = {
cliente: modelo(clientes, () => ({ id: nid("c"), telefonoNorm: null, email: null, createdAt: new Date() })),
jefeMecanico: modelo(jefes),
tipoServicio: modelo(servicios),
conversacionWhatsapp: modelo(convs, () => ({
id: nid("conv"),
paso: "INICIO",
datos: {},
clienteId: null,
createdAt: new Date(),
updatedAt: new Date(),
})),
turno: {
async count({ where }: any = {}) {
return turnos.filter((x) => match(x, where || {})).length;
},
async create({ data, include }: any) {
const row: Row = {
id: nid("t"),
estado: "PENDIENTE",
autoId: null,
kilometros: null,
comentario: null,
createdAt: new Date(),
...data,
};
turnos.push(row);
const out: Row = { ...row };
if (include?.auto) out.auto = row.autoId ? { patente: "?", marca: "?", modelo: "?", anio: 0 } : null;
if (include?.tipoServicio) out.tipoServicio = servicios.find((s) => s.id === row.tipoServicioId) ?? null;
if (include?.jefeMecanico) {
const j = jefes.find((x) => x.id === row.jefeMecanicoId);
out.jefeMecanico = j ? { id: j.id, nombre: j.nombre } : null;
}
return out;
},
},
// `crearTurno` verifica y crea dentro de una transacción con un lock por jefe.
// Acá corre todo sobre el mismo cliente falso, que ya es secuencial.
async $transaction(fn: any) {
return fn(fakePrisma);
},
async $executeRaw() {
return 0;
},
};
(globalThis as any).prisma = fakePrisma;
// ── Seed ──
jefes.push({ id: "j1", nombre: "Carlos", email: "j1@t.com", activo: true, slotsParalelos: 1 });
jefes.push({ id: "j2", nombre: "Diego", email: "j2@t.com", activo: true, slotsParalelos: 1 });
servicios.push({ id: "s1", nombre: "Cambio de aceite", duracionHoras: 1, activo: true, jefeMecanicoId: "j1" });
servicios.push({ id: "s2", nombre: "Alineacion", duracionHoras: 2, activo: true, jefeMecanicoId: "j1" });
// Trabajo global del taller (lo carga el admin): lo ofrece cualquier jefe.
servicios.push({ id: "s3", nombre: "Revision general", duracionHoras: 1, activo: true, jefeMecanicoId: null });
clientes.push({
id: "c1",
nombre: "Juan",
apellido: "Perez",
email: "juan@mail.com",
telefono: "5493510000001",
telefonoNorm: "5493510000001",
createdAt: new Date(),
});
// ── Runner de asserts ──
let fallos = 0;
function ok(cond: boolean, msg: string) {
console.log(`${cond ? " ✅" : " ❌"} ${msg}`);
if (!cond) fallos++;
}
const flat = (resp: any[]) =>
resp
.map((x) => (x.tipo === "botones" ? `${x.texto} [${x.botones.map((b: any) => b.titulo).join(",")}]` : x.texto))
.join(" | ");
const hayBotones = (resp: any[]) => resp.some((x) => x.tipo === "botones");
const paso = (tel: string) => convs.find((c) => c.telefono === tel)?.paso;
async function main() {
const { procesarMensaje } = await import("../src/lib/whatsapp-bot");
const env = (tel: string, txt: string) => procesarMensaje(tel, txt);
console.log("\n── Escenario A: cliente NUEVO, alta + reserva completa ──");
const A = "5493519999999";
let r = await env(A, "hola");
ok(/asistente/i.test(flat(r)) && /nombre/i.test(flat(r)), "bienvenida + pide nombre");
ok(paso(A) === "ALTA_NOMBRE", "paso=ALTA_NOMBRE");
r = await env(A, "Pedro");
ok(/apellido/i.test(flat(r)), "pide apellido");
r = await env(A, "Gomez");
ok(/email/i.test(flat(r)), "pide email");
r = await env(A, "pedro@mail.com");
ok(/registrado/i.test(flat(r)) && hayBotones(r), "registrado + botones de mecánico");
ok(paso(A) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO");
ok(!!clientes.find((c) => c.telefonoNorm === A), "cliente creado con telefonoNorm");
r = await env(A, "j1");
ok(/Cambio de aceite/i.test(flat(r)), "muestra trabajos del jefe elegido");
ok(/Revision general/i.test(flat(r)), "incluye los trabajos globales del taller");
ok(paso(A) === "ELEGIR_TRABAJO", "paso=ELEGIR_TRABAJO");
r = await env(A, "a");
ok(/Horarios/i.test(flat(r)), "muestra horarios");
ok(paso(A) === "ELEGIR_HORARIO", "paso=ELEGIR_HORARIO");
r = await env(A, "a");
ok(/Confirmás/i.test(flat(r)), "pide confirmación");
ok(paso(A) === "CONFIRMAR", "paso=CONFIRMAR");
r = await env(A, "sí");
ok(/confirmado/i.test(flat(r)), "turno confirmado");
ok(paso(A) === "INICIO", "vuelve a INICIO tras reservar");
const nuevoCliente = clientes.find((c) => c.telefonoNorm === A)!;
const t = turnos.find((x) => x.clienteId === nuevoCliente.id);
ok(!!t, "se creó el turno del cliente");
ok(t?.autoId === null && t?.kilometros === null, "turno sin auto/km (los carga el jefe)");
console.log("\n── Escenario B: cliente EXISTENTE, verificación por email ──");
const B = "5493510000001";
r = await env(B, "hola");
ok(/email/i.test(flat(r)), "pide el email registrado");
ok(paso(B) === "VERIFICAR_EMAIL", "paso=VERIFICAR_EMAIL");
r = await env(B, "nope@x.com");
ok(/no coincide/i.test(flat(r)), "rechaza email incorrecto");
ok(paso(B) === "VERIFICAR_EMAIL", "sigue en VERIFICAR_EMAIL");
r = await env(B, "juan@mail.com");
ok(hayBotones(r), "email correcto → botones de mecánico");
ok(paso(B) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO");
console.log("\n── Escenario C: opción inválida + cancelar ──");
r = await env(B, "zzz");
ok(/no entendí/i.test(flat(r)), "opción inválida → 'no entendí'");
ok(paso(B) === "ELEGIR_MECANICO", "no avanza con opción inválida");
r = await env(B, "cancelar");
ok(/cancel/i.test(flat(r)), "cancelar resetea");
ok(paso(B) === "INICIO", "paso=INICIO tras cancelar");
console.log("\n── Escenario D: trabajos por jefe ──");
const D = "5493518888888";
await env(D, "hola");
await env(D, "Ana");
await env(D, "Lopez");
r = await env(D, "ana@mail.com");
ok(hayBotones(r), "alta rápida hasta elegir mecánico");
r = await env(D, "j2");
ok(/Revision general/i.test(flat(r)), "un jefe sin trabajos propios igual ofrece los globales");
ok(!/Cambio de aceite/i.test(flat(r)), "NO ofrece los trabajos propios de otro jefe");
const { crearTurno } = await import("../src/lib/turnos");
const cruzado = await crearTurno({
clienteId: "c1",
jefeMecanicoId: "j2",
tipoServicioId: "s1", // trabajo propio de j1
fechaInicio: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
ok(!cruzado.ok && cruzado.status === 400, "crearTurno rechaza el trabajo de otro jefe (400)");
console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`);
process.exit(fallos === 0 ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+181
View File
@@ -0,0 +1,181 @@
/**
* Test de la capa que habla con Meta (F0) SIN base de datos ni credenciales reales.
*
* Cubre las dos direcciones de `src/lib/whatsapp.ts`:
* - salida: RespuestaBot → payload de la Graph API (con `fetch` stubbeado)
* - entrada: payload del webhook → mensajes/texto
* - firma X-Hub-Signature-256
*
* Correr con: npx tsx scripts/test-whatsapp-envio.ts
*/
import crypto from "crypto";
process.env.WHATSAPP_TOKEN = "token-de-prueba";
process.env.WHATSAPP_PHONE_NUMBER_ID = "111222333";
process.env.WHATSAPP_APP_SECRET = "secreto-de-prueba";
import {
enviarRespuestas,
firmaValida,
mensajesDelPayload,
textoDeMensaje,
whatsappConfigurado,
} from "../src/lib/whatsapp";
let fallos = 0;
function ok(cond: boolean, msg: string) {
console.log(` ${cond ? "✅" : "❌"} ${msg}`);
if (!cond) fallos++;
}
// ── Stub de fetch: guarda lo que se le manda a Meta en vez de salir a internet ──
type Llamada = { url: string; auth: string; body: any };
let llamadas: Llamada[] = [];
// Códigos a devolver, en orden. Si se vacía, responde 200.
let respuestas: number[] = [];
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
llamadas.push({
url: String(url),
auth: headers["Authorization"] ?? "",
body: JSON.parse(String(init?.body ?? "{}")),
});
const status = respuestas.shift() ?? 200;
return { ok: status < 400, status, text: async () => "detalle" } as Response;
}) as typeof fetch;
async function main() {
console.log("\n── Config ──");
ok(whatsappConfigurado(), "detecta las credenciales presentes");
console.log("\n── Salida: texto ──");
llamadas = [];
await enviarRespuestas("5493511234567", [{ tipo: "texto", texto: "Hola *mundo*" }]);
ok(llamadas.length === 1, "manda un solo request");
ok(llamadas[0].url.includes("/111222333/messages"), "pega al phone_number_id configurado");
ok(llamadas[0].auth === "Bearer token-de-prueba", "manda el bearer token");
ok(llamadas[0].body.messaging_product === "whatsapp", "incluye messaging_product");
ok(llamadas[0].body.type === "text", "type=text");
ok(llamadas[0].body.to === "5493511234567", "va al teléfono correcto");
ok(llamadas[0].body.text.body === "Hola *mundo*", "respeta el markup de WhatsApp");
console.log("\n── Salida: botones ──");
llamadas = [];
await enviarRespuestas("549351", [
{
tipo: "botones",
texto: "¿Con qué mecánico?",
botones: [
{ id: "j1", titulo: "Carlos" },
{ id: "j2", titulo: "Un nombre larguísimo que no entra" },
{ id: "j3", titulo: "Ana" },
{ id: "j4", titulo: "Cuarto" },
],
},
]);
const inter = llamadas[0].body.interactive;
ok(llamadas[0].body.type === "interactive", "type=interactive");
ok(inter.type === "button", "interactive.type=button");
ok(inter.body.text === "¿Con qué mecánico?", "el texto va en body.text");
ok(inter.action.buttons.length === 3, "recorta a los 3 botones que permite Meta");
ok(inter.action.buttons[1].reply.title.length <= 20, "trunca títulos a 20 caracteres");
ok(inter.action.buttons[0].reply.id === "j1", "conserva el id de la opción");
ok(inter.action.buttons[0].type === "reply", "los botones son de tipo reply");
console.log("\n── Salida: casos borde ──");
llamadas = [];
await enviarRespuestas("549351", [{ tipo: "botones", texto: "sin opciones", botones: [] }]);
ok(llamadas[0].body.type === "text", "sin botones cae a mensaje de texto");
llamadas = [];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "x".repeat(5000) }]);
ok(llamadas[0].body.text.body.length === 4096, "trunca el texto al máximo de Meta");
llamadas = [];
await enviarRespuestas("549351", [
{ tipo: "texto", texto: "uno" },
{ tipo: "texto", texto: "dos" },
]);
ok(llamadas.length === 2, "manda las respuestas múltiples en orden");
ok(llamadas[0].body.text.body === "uno" && llamadas[1].body.text.body === "dos", "respeta el orden");
console.log("\n── Salida: reintentos ──");
llamadas = [];
respuestas = [429];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "con rate limit" }]);
ok(llamadas.length === 2, "reintenta ante un 429 y sale bien en el segundo intento");
llamadas = [];
respuestas = [500, 503];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "con 5xx" }]);
ok(llamadas.length === 3, "reintenta ante 5xx hasta que uno sale bien");
llamadas = [];
respuestas = [500, 500, 500];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "siempre 5xx" }]);
ok(llamadas.length === 3, "no reintenta más de 3 veces");
llamadas = [];
respuestas = [400];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "payload malo" }]);
ok(llamadas.length === 1, "un 400 no se reintenta (es error nuestro)");
llamadas = [];
respuestas = [401];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "token vencido" }]);
ok(llamadas.length === 1, "un 401 no se reintenta");
respuestas = [];
console.log("\n── Firma del webhook ──");
const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" });
const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex");
ok(firmaValida(cuerpo, firma), "acepta la firma correcta");
ok(!firmaValida(cuerpo + " ", firma), "rechaza si el cuerpo fue alterado");
ok(!firmaValida(cuerpo, "sha256=" + "0".repeat(64)), "rechaza una firma inválida");
ok(!firmaValida(cuerpo, null), "rechaza si no viene el header");
ok(!firmaValida(cuerpo, firma.slice("sha256=".length)), "rechaza sin el prefijo sha256=");
console.log("\n── Entrada: payload del webhook ──");
const payload = {
entry: [
{
changes: [
{
value: {
messages: [
{ from: "549351", id: "wamid.1", type: "text", text: { body: "hola" } },
{
from: "549351",
id: "wamid.2",
type: "interactive",
interactive: { button_reply: { id: "jefe-abc", title: "Carlos" } },
},
{ from: "549351", id: "wamid.3", type: "audio", audio: { id: "x" } },
],
},
},
],
},
],
};
const msgs = mensajesDelPayload(payload);
ok(msgs.length === 3, "aplana los 3 mensajes del payload");
ok(textoDeMensaje(msgs[0]) === "hola", "extrae el texto plano");
ok(textoDeMensaje(msgs[1]) === "jefe-abc", "de un botón extrae el id (lo que espera el bot)");
ok(textoDeMensaje(msgs[2]) === null, "un audio devuelve null → el bot no lo procesa");
ok(mensajesDelPayload({}).length === 0, "payload vacío no rompe");
ok(
mensajesDelPayload({ entry: [{ changes: [{ value: {} }] }] }).length === 0,
"un evento de status (sin messages) no genera mensajes"
);
console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`);
process.exit(fallos === 0 ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
+2 -2
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
@@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, descripcion, observaciones, fechaRealizado } = await req.json();
+1 -1
View File
@@ -4,7 +4,7 @@ import { verificarSesion } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
+2 -2
View File
@@ -4,7 +4,7 @@ import { verificarSesion } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function GET() {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefes = await prisma.jefeMecanico.findMany({
@@ -15,7 +15,7 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, email, password, slotsParalelos } = await req.json();
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
+2 -2
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({ orderBy: { nombre: "asc" } });
@@ -11,7 +11,7 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json();
+18 -3
View File
@@ -4,19 +4,34 @@ import { verificarSesion } from "@/lib/auth-admin";
import { agregarFilaSheet } from "@/lib/google-sheets";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const { estado, comentario } = await req.json();
// Regla: no se puede cerrar (COMPLETADO) un turno sin auto y kilometraje cargados.
// Las reservas por WhatsApp los dejan vacíos; el jefe mecánico debe completarlos antes de cerrar.
if (estado === "COMPLETADO") {
const actual = await prisma.turno.findUnique({
where: { id },
select: { autoId: true, kilometros: true },
});
if (!actual) return NextResponse.json({ error: "Turno no encontrado" }, { status: 404 });
if (!actual.autoId || actual.kilometros == null)
return NextResponse.json(
{ error: "Cargá el auto y el kilometraje antes de cerrar el turno" },
{ status: 400 }
);
}
const turno = await prisma.turno.update({
where: { id },
data: { estado, ...(comentario !== undefined && { comentario }) },
include: { auto: true, tipoServicio: true, cliente: true },
});
if (estado === "COMPLETADO") {
if (estado === "COMPLETADO" && turno.auto && turno.kilometros != null) {
try {
await agregarFilaSheet({
patente: turno.auto.patente,
@@ -26,7 +41,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
anio: turno.auto.anio,
nombre: turno.cliente.nombre,
apellido: turno.cliente.apellido,
email: turno.cliente.email,
email: turno.cliente.email ?? "",
telefono: turno.cliente.telefono,
fecha: turno.fechaInicio,
trabajo: turno.tipoServicio.nombre,
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
+2 -2
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const autos = await prisma.auto.findMany({
@@ -15,7 +15,7 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { patente, marca, modelo, anio, nroChasis } = await req.json();
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefes = await prisma.jefeMecanico.findMany({
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { aFormatoWhatsapp, aLocalArgentino, esLocalArgentinoValido } from "@/lib/telefono";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Datos de contacto del cliente logueado. El teléfono se devuelve en formato local
// (sin el +54 9) porque el formulario lo muestra como prefijo fijo.
export async function GET() {
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const cliente = await prisma.cliente.findUnique({
where: { id: sesion.sub },
select: { nombre: true, apellido: true, email: true, telefono: true, telefonoNorm: true },
});
if (!cliente) return NextResponse.json({ error: "Cliente no encontrado" }, { status: 404 });
return NextResponse.json({
nombre: cliente.nombre,
apellido: cliente.apellido,
email: cliente.email ?? "",
telefono: aLocalArgentino(cliente.telefonoNorm ?? cliente.telefono ?? ""),
});
}
export async function PATCH(req: NextRequest) {
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, apellido, email, telefono } = await req.json();
if (!nombre?.trim() || !apellido?.trim())
return NextResponse.json({ error: "Nombre y apellido son obligatorios" }, { status: 400 });
if (!email?.trim() || !EMAIL_RE.test(email.trim()))
return NextResponse.json({ error: "El email no es válido" }, { status: 400 });
if (!esLocalArgentinoValido(telefono ?? ""))
return NextResponse.json(
{ error: "El teléfono debe tener 10 dígitos, sin el 0 de la característica ni el 15" },
{ status: 400 }
);
const emailNuevo = email.trim().toLowerCase();
const telefonoNorm = aFormatoWhatsapp(telefono);
// El email es con lo que se entra al sistema y el teléfono es con lo que el bot
// reconoce al cliente: los dos tienen que seguir siendo únicos.
const emailTomado = await prisma.cliente.findFirst({
where: { email: emailNuevo, id: { not: sesion.sub } },
select: { id: true },
});
if (emailTomado)
return NextResponse.json({ error: "Ese email ya está registrado" }, { status: 409 });
const telefonoTomado = await prisma.cliente.findFirst({
where: { telefonoNorm, id: { not: sesion.sub } },
select: { id: true },
});
if (telefonoTomado)
return NextResponse.json({ error: "Ese teléfono ya está registrado" }, { status: 409 });
// Entre la comprobación de arriba y este update otra request puede tomar el mismo
// email o teléfono; los índices únicos lo cortan y acá se traduce a un 409 legible.
let cliente;
try {
cliente = await prisma.cliente.update({
where: { id: sesion.sub },
data: {
nombre: nombre.trim(),
apellido: apellido.trim(),
email: emailNuevo,
telefono: aLocalArgentino(telefonoNorm),
telefonoNorm,
},
select: { nombre: true, apellido: true, email: true, telefonoNorm: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002")
return NextResponse.json(
{ error: "Ese email o teléfono ya está registrado" },
{ status: 409 }
);
throw e;
}
return NextResponse.json({
ok: true,
nombre: cliente.nombre,
apellido: cliente.apellido,
email: cliente.email ?? "",
telefono: aLocalArgentino(cliente.telefonoNorm ?? ""),
});
}
+20 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { aFormatoWhatsapp, esLocalArgentinoValido, normalizarTelefono } from "@/lib/telefono";
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
@@ -18,11 +19,29 @@ export async function POST(req: NextRequest) {
if (!email || !nombre || !apellido || !telefono)
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
if (!esLocalArgentinoValido(telefono))
return NextResponse.json(
{ error: "El teléfono debe tener 10 dígitos, sin el 0 de la característica ni el 15" },
{ status: 400 }
);
const existe = await prisma.cliente.findUnique({ where: { email: email.toLowerCase() } });
if (existe) return NextResponse.json({ error: "El email ya está registrado" }, { status: 409 });
// `telefonoNorm` queda igual al `wa_id` que manda Meta, que es como lo busca el bot.
const telefonoNorm = aFormatoWhatsapp(telefono);
const yaUsado = await prisma.cliente.findFirst({ where: { telefonoNorm } });
if (yaUsado)
return NextResponse.json({ error: "Ese teléfono ya está registrado" }, { status: 409 });
const cliente = await prisma.cliente.create({
data: { email: email.toLowerCase().trim(), nombre, apellido, telefono },
data: {
email: email.toLowerCase().trim(),
nombre,
apellido,
telefono: normalizarTelefono(telefono),
telefonoNorm,
},
});
await transporter.sendMail({
+19 -4
View File
@@ -1,13 +1,28 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { whereTrabajosDeJefe } from "@/lib/servicios";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
// Trabajos que ofrece un jefe mecánico: los suyos + los globales del taller.
// El flujo del cliente es jefe → trabajo, así que el jefe es obligatorio.
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId");
if (!jefeMecanicoId)
return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 });
// Un jefe dado de baja no debe exponer sus trabajos (ni dejar arrancar una reserva).
const jefe = await prisma.jefeMecanico.findUnique({
where: { id: jefeMecanicoId },
select: { activo: true },
});
if (!jefe || !jefe.activo)
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 });
const servicios = await prisma.tipoServicio.findMany({
where: { activo: true },
where: whereTrabajosDeJefe(jefeMecanicoId),
orderBy: { nombre: "asc" },
});
+4 -1
View File
@@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { obtenerSlotsDisponibles } from "@/lib/scheduling";
import { trabajoEsDeJefe } from "@/lib/servicios";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
@@ -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))
return NextResponse.json({ error: "Ese trabajo no lo hace este mecánico" }, { status: 400 });
const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500);
return NextResponse.json(slots);
+14 -30
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { slotEstaDisponible } from "@/lib/scheduling";
import { crearTurno } from "@/lib/turnos";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const turnos = await prisma.turno.findMany({
@@ -17,7 +17,7 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json();
@@ -34,33 +34,17 @@ export async function POST(req: NextRequest) {
});
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } });
if (!jefe || !jefe.activo)
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 });
const inicio = new Date(fechaInicio);
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
const disponible = await slotEstaDisponible(inicio, fin, jefeMecanicoId);
if (!disponible)
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
const turno = await prisma.turno.create({
data: {
clienteId: sesion.sub,
autoId,
tipoServicioId,
jefeMecanicoId,
fechaInicio: inicio,
fechaFin: fin,
kilometros: km,
},
include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
const resultado = await crearTurno({
clienteId: sesion.sub,
jefeMecanicoId,
tipoServicioId,
fechaInicio: new Date(fechaInicio),
autoId,
kilometros: km,
});
return NextResponse.json(turno, { status: 201 });
if (!resultado.ok)
return NextResponse.json({ error: resultado.error }, { status: resultado.status });
return NextResponse.json(resultado.turno, { status: 201 });
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
// Edita un trabajo propio del jefe (nombre, duración, activo). Solo si le pertenece.
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const propio = await prisma.tipoServicio.findFirst({
where: { id, jefeMecanicoId: sesion.sub },
});
if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 });
const data = await req.json();
if (data.duracionHoras !== undefined) {
const dur = Number(data.duracionHoras);
if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" },
{ status: 400 }
);
}
if (data.nombre !== undefined && (typeof data.nombre !== "string" || !data.nombre.trim()))
return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 });
const servicio = await prisma.tipoServicio.update({
where: { id },
data: {
...(data.nombre !== undefined && { nombre: data.nombre.trim() }),
...(data.duracionHoras !== undefined && { duracionHoras: Number(data.duracionHoras) }),
...(data.activo !== undefined && { activo: Boolean(data.activo) }),
},
});
return NextResponse.json(servicio);
}
// Elimina un trabajo propio. Si ya tiene turnos asociados, lo desactiva en vez de borrarlo
// (para no romper la integridad referencial de los turnos históricos).
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const propio = await prisma.tipoServicio.findFirst({
where: { id, jefeMecanicoId: sesion.sub },
});
if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 });
const enUso = await prisma.turno.count({ where: { tipoServicioId: id } });
if (enUso > 0) {
const servicio = await prisma.tipoServicio.update({
where: { id },
data: { activo: false },
});
return NextResponse.json({ ...servicio, desactivado: true });
}
await prisma.tipoServicio.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
// Lista los trabajos cargados por el jefe logueado (los suyos, no los globales).
export async function GET() {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({
where: { jefeMecanicoId: sesion.sub },
orderBy: { nombre: "asc" },
});
return NextResponse.json(servicios);
}
// Crea un trabajo propio del jefe (nombre + duración en horas).
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json();
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)
return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" },
{ status: 400 }
);
const servicio = await prisma.tipoServicio.create({
data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub },
});
return NextResponse.json(servicio, { status: 201 });
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("jefe_session");
if (!sesion || sesion.role !== "jefe")
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
+111
View File
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { procesarMensaje } from "@/lib/whatsapp-bot";
import {
enviarRespuestas,
firmaValida,
marcarLeido,
mensajesDelPayload,
textoDeMensaje,
type PayloadWebhook,
} from "@/lib/whatsapp";
export const dynamic = "force-dynamic";
/**
* Webhook de WhatsApp Business Cloud API.
* GET → verificación del webhook al configurarlo en el panel de Meta.
* POST → mensajes entrantes: se los pasa a la máquina de conversación y se contesta.
*/
// Meta reintenta el mismo mensaje si tardamos o si algo falla, y sin deduplicar eso
// crea el turno dos veces. Se resuelve insertando el id en la base: el índice único
// hace la operación atómica, así que sirve incluso con varias instancias corriendo.
const DIAS_RETENCION = 7;
let ultimaLimpieza = 0;
async function yaProcesado(id: string): Promise<boolean> {
try {
await prisma.mensajeWhatsappProcesado.create({ data: { id } });
return false;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") return true;
throw e;
}
}
/** Borra los ids viejos como mucho una vez por día. Best-effort: si falla, no importa. */
async function limpiarProcesadosViejos(): Promise<void> {
const unDia = 24 * 60 * 60 * 1000;
if (Date.now() - ultimaLimpieza < unDia) return;
ultimaLimpieza = Date.now();
try {
await prisma.mensajeWhatsappProcesado.deleteMany({
where: { createdAt: { lt: new Date(Date.now() - DIAS_RETENCION * unDia) } },
});
} catch (e) {
console.error("[whatsapp] no se pudieron limpiar los mensajes procesados:", e);
}
}
export async function GET(req: NextRequest) {
const params = req.nextUrl.searchParams;
const modo = params.get("hub.mode");
const token = params.get("hub.verify_token");
const challenge = params.get("hub.challenge");
const esperado = process.env.WHATSAPP_VERIFY_TOKEN;
if (modo === "subscribe" && esperado && token === esperado && challenge)
return new NextResponse(challenge, { status: 200, headers: { "Content-Type": "text/plain" } });
return new NextResponse("Forbidden", { status: 403 });
}
export async function POST(req: NextRequest) {
// La firma se calcula sobre el cuerpo CRUDO: leerlo como texto y recién ahí parsear.
const cuerpoCrudo = await req.text();
if (!firmaValida(cuerpoCrudo, req.headers.get("x-hub-signature-256"))) {
console.warn("[whatsapp] webhook con firma inválida — descartado");
return new NextResponse("Forbidden", { status: 403 });
}
let body: PayloadWebhook;
try {
body = JSON.parse(cuerpoCrudo) as PayloadWebhook;
} catch {
return NextResponse.json({ ok: true });
}
try {
await limpiarProcesadosViejos();
for (const mensaje of mensajesDelPayload(body)) {
const telefono = mensaje.from;
const messageId = mensaje.id;
if (!telefono || !messageId || (await yaProcesado(messageId))) continue;
void marcarLeido(messageId).catch(() => {});
const texto = textoDeMensaje(mensaje);
if (texto === null) {
await enviarRespuestas(telefono, [
{
tipo: "texto",
texto: "Por ahora solo entiendo mensajes de texto. Escribime qué necesitás. 🙂",
},
]);
continue;
}
const respuestas = await procesarMensaje(telefono, texto);
await enviarRespuestas(telefono, respuestas);
}
} catch (e) {
// Nunca devolvemos error a Meta: reintentaría el mismo mensaje en loop.
console.error("[whatsapp] error procesando webhook:", e);
}
return NextResponse.json({ ok: true });
}
+8 -3
View File
@@ -68,9 +68,14 @@ export default function DashboardPage() {
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Mis autos</h1>
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
Salir
</button>
<div className="flex items-center gap-4">
<Link href="/cliente/perfil" className="text-sm text-gray-200 hover:text-white transition">
Mis datos
</Link>
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
Salir
</button>
</div>
</div>
{loading ? (
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input";
import { esLocalArgentinoValido } from "@/lib/telefono";
interface Perfil {
nombre: string;
apellido: string;
email: string;
telefono: string;
}
const VACIO: Perfil = { nombre: "", apellido: "", email: "", telefono: "" };
export default function PerfilPage() {
const router = useRouter();
const [perfil, setPerfil] = useState<Perfil>(VACIO);
const [form, setForm] = useState<Perfil>(VACIO);
const [loading, setLoading] = useState(true);
const [editando, setEditando] = useState(false);
const [guardando, setGuardando] = useState(false);
const [error, setError] = useState("");
const [telefonoError, setTelefonoError] = useState("");
const [guardado, setGuardado] = useState(false);
useEffect(() => {
fetch("/api/cliente/perfil")
.then((r) => {
if (r.status === 401) { router.push("/cliente"); return null; }
if (!r.ok) throw new Error("respuesta con error");
return r.json();
})
.then((data) => {
if (!data) return;
setPerfil(data);
setForm(data);
setLoading(false);
})
// Sin esto, si el pedido falla la pantalla se queda en "Cargando..." para siempre.
.catch(() => {
setError("No pudimos cargar tus datos. Probá recargar la página.");
setLoading(false);
});
}, [router]);
function set(campo: keyof Perfil) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [campo]: e.target.value }));
}
function handleTelefono(e: React.ChangeEvent<HTMLInputElement>) {
const soloDigitos = e.target.value.replace(/\D/g, "").slice(0, 10);
setForm((f) => ({ ...f, telefono: soloDigitos }));
if (soloDigitos.startsWith("0")) setTelefonoError("Sin el 0 de la característica");
else if (soloDigitos.startsWith("15")) setTelefonoError("Sin el 15, poné la característica");
else setTelefonoError("");
}
function cancelar() {
setForm(perfil);
setEditando(false);
setError("");
setTelefonoError("");
}
async function guardar(e: React.FormEvent) {
e.preventDefault();
if (!esLocalArgentinoValido(form.telefono)) {
setTelefonoError("Son 10 dígitos: característica sin el 0 y número sin el 15");
return;
}
setGuardando(true);
setError("");
const res = await fetch("/api/cliente/perfil", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
// Si la sesión venció mientras editaba, mandarlo a entrar de nuevo en vez de
// mostrarle un error suelto que no explica nada.
if (res.status === 401) { router.push("/cliente"); return; }
const data = await res.json();
setGuardando(false);
if (!res.ok) { setError(data.error || "No se pudieron guardar los cambios"); return; }
setPerfil(data);
setForm(data);
setEditando(false);
setGuardado(true);
setTimeout(() => setGuardado(false), 4000);
}
if (loading) return <div className="text-center py-10 text-gray-200">Cargando...</div>;
const emailCambio = editando && form.email.trim().toLowerCase() !== perfil.email.toLowerCase();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
<h1 className="text-xl sm:text-2xl font-bold text-white">Mis datos</h1>
</div>
<Card>
{!editando ? (
<div className="flex flex-col gap-4">
<Dato etiqueta="Nombre" valor={`${perfil.nombre} ${perfil.apellido}`} />
<Dato etiqueta="Email" valor={perfil.email || "—"} />
<Dato etiqueta="Teléfono" valor={perfil.telefono ? `+54 9 ${perfil.telefono}` : "—"} />
{guardado && <p className="text-sm text-green-600">✅ Datos actualizados</p>}
<Button onClick={() => setEditando(true)} className="self-start">
Modificar datos
</Button>
</div>
) : (
<form onSubmit={guardar} className="flex flex-col gap-4">
<div className="grid sm:grid-cols-2 gap-4">
<Input id="nombre" label="Nombre" value={form.nombre} onChange={set("nombre")} required />
<Input id="apellido" label="Apellido" value={form.apellido} onChange={set("apellido")} required />
</div>
<Input
id="email"
type="email"
label="Email"
value={form.email}
onChange={set("email")}
ayuda={emailCambio ? "Ojo: con este email vas a entrar de ahora en más." : undefined}
required
/>
<Input
id="telefono"
type="tel"
inputMode="numeric"
label="Teléfono (celular)"
prefijo="+54 9"
placeholder="1141984844"
maxLength={10}
ayuda="Es el celular con el que sacás turnos por WhatsApp."
error={telefonoError}
value={form.telefono}
onChange={handleTelefono}
required
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
<Button variant="secondary" type="button" onClick={cancelar}>Cancelar</Button>
<Button type="submit" loading={guardando}>Guardar cambios</Button>
</div>
</form>
)}
</Card>
</div>
);
}
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div>
<p className="text-sm text-gray-500">{etiqueta}</p>
<p className="font-medium text-gray-800">{valor}</p>
</div>
);
}
+38 -1
View File
@@ -5,19 +5,42 @@ import Link from "next/link";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
import { esLocalArgentinoValido } from "@/lib/telefono";
export default function RegistroPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", nombre: "", apellido: "", telefono: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [telefonoError, setTelefonoError] = useState("");
function set(field: string) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [field]: e.target.value }));
}
// El teléfono se guarda como lo espera WhatsApp: solo característica + abonado,
// sin el 0 ni el 15 (el +54 9 lo pone el prefijo fijo del campo).
function handleTelefono(e: React.ChangeEvent<HTMLInputElement>) {
const soloDigitos = e.target.value.replace(/\D/g, "").slice(0, 10);
setForm((f) => ({ ...f, telefono: soloDigitos }));
// Mientras tipea sólo se avisa lo que ya es un error seguro; lo de "faltan
// dígitos" espera a que salga del campo, para no retarlo por la mitad.
if (soloDigitos.startsWith("0")) setTelefonoError("Sin el 0 de la característica");
else if (soloDigitos.startsWith("15")) setTelefonoError("Sin el 15, poné la característica");
else setTelefonoError("");
}
function handleTelefonoBlur() {
if (form.telefono && !esLocalArgentinoValido(form.telefono))
setTelefonoError("Son 10 dígitos: característica sin el 0 y número sin el 15");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!esLocalArgentinoValido(form.telefono)) {
setTelefonoError("Revisá el número: son 10 dígitos, sin el 0 y sin el 15");
return;
}
setLoading(true);
setError("");
const res = await fetch("/api/cliente/registro", {
@@ -47,7 +70,21 @@ export default function RegistroPage() {
<Input id="nombre" label="Nombre" placeholder="Juan" value={form.nombre} onChange={set("nombre")} required />
<Input id="apellido" label="Apellido" placeholder="Pérez" value={form.apellido} onChange={set("apellido")} required />
<Input id="email" type="email" label="Email" placeholder="juan@email.com" value={form.email} onChange={set("email")} required />
<Input id="telefono" type="tel" label="Teléfono" placeholder="11 1234-5678" value={form.telefono} onChange={set("telefono")} required />
<Input
id="telefono"
type="tel"
inputMode="numeric"
label="Teléfono (celular)"
prefijo="+54 9"
placeholder="1141984844"
maxLength={10}
ayuda="Característica sin el 0 y número sin el 15. Es el celular con el que vas a poder sacar turnos por WhatsApp."
error={telefonoError}
value={form.telefono}
onChange={handleTelefono}
onBlur={handleTelefonoBlur}
required
/>
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
<Button type="submit" loading={loading} className="w-full">
Crear cuenta
+69 -50
View File
@@ -20,7 +20,9 @@ interface Turno {
const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"];
type Step = "servicio" | "jefe" | "calendario" | "confirmado";
// El cliente elige primero el mecánico y después el trabajo, porque cada jefe
// carga sus propios trabajos (además de los globales del taller).
type Step = "jefe" | "servicio" | "calendario" | "confirmado";
function formatDuracion(h: number) {
if (h < 1) return `${Math.round(h * 60)} min`;
@@ -66,12 +68,13 @@ function TurnosContent() {
const params = useSearchParams();
const autoId = params.get("autoId");
const [step, setStep] = useState<Step>("servicio");
const [step, setStep] = useState<Step>("jefe");
const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [loadingServicios, setLoadingServicios] = useState(false);
const [jefes, setJefes] = useState<Jefe[]>([]);
const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
const [loadingJefes, setLoadingJefes] = useState(false);
const [loadingJefes, setLoadingJefes] = useState(true);
const [slots, setSlots] = useState<Slot[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
@@ -90,7 +93,12 @@ function TurnosContent() {
useEffect(() => {
if (!autoId) { router.push("/cliente/dashboard"); return; }
fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios);
fetch("/api/cliente/jefes")
.then(r => r.json())
.then((data) => {
setJefes(Array.isArray(data) ? data : []);
setLoadingJefes(false);
});
fetch("/api/cliente/turnos")
.then(r => r.json())
.then((data) => {
@@ -110,29 +118,30 @@ function TurnosContent() {
return m;
}, [slots]);
async function seleccionarServicio(s: TipoServicio) {
setServicioSel(s);
setJefeSel(null);
async function seleccionarJefe(j: Jefe) {
setJefeSel(j);
setServicioSel(null);
setServicios([]);
setSlots([]);
setDiaSeleccionado(null);
setSlotSel(null);
setLoadingJefes(true);
setStep("jefe");
const res = await fetch("/api/cliente/jefes");
setLoadingServicios(true);
setStep("servicio");
const res = await fetch(`/api/cliente/servicios?jefeMecanicoId=${j.id}`);
const data = await res.json();
setJefes(Array.isArray(data) ? data : []);
setLoadingJefes(false);
setServicios(Array.isArray(data) ? data : []);
setLoadingServicios(false);
}
async function seleccionarJefe(j: Jefe) {
if (!servicioSel) return;
setJefeSel(j);
async function seleccionarServicio(s: TipoServicio) {
if (!jefeSel) return;
setServicioSel(s);
setLoadingSlots(true);
setSlots([]);
setDiaSeleccionado(null);
setSlotSel(null);
setStep("calendario");
const res = await fetch(`/api/cliente/slots?tipoServicioId=${servicioSel.id}&jefeMecanicoId=${j.id}`);
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}&jefeMecanicoId=${jefeSel.id}`);
const data = await res.json();
setSlots(Array.isArray(data) ? data : []);
setLoadingSlots(false);
@@ -213,14 +222,14 @@ function TurnosContent() {
<div className="text-5xl">✅</div>
<h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1>
<Card className="text-left w-full max-w-sm">
<p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
{turnoCreado.jefeMecanico && (
<>
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p>
<p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p>
</>
)}
<p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
<p className="text-sm text-gray-500 mb-1">Fecha</p>
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
@@ -236,33 +245,46 @@ function TurnosContent() {
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
{step === "calendario" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : step === "jefe" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : step === "servicio" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : (
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
)}
<h1 className="text-xl sm:text-2xl font-bold text-white">
{step === "servicio" && "Elegí el servicio"}
{step === "jefe" && "Elegí el jefe mecánico"}
{step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`}
{step === "servicio" && `Trabajos de ${jefeSel?.nombre}`}
{step === "calendario" && `${jefeSel?.nombre}${servicioSel ? ` · ${servicioSel.nombre}` : ""}`}
</h1>
</div>
{/* STEP: servicio */}
{step === "servicio" && (
{/* STEP: jefe */}
{step === "jefe" && (
<>
<div className="grid gap-3">
{servicios.map(s => (
<button key={s.id} onClick={() => seleccionarServicio(s)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))}
<div className="flex flex-col gap-3">
{loadingJefes ? (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
) : jefes.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 text-center">
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
</p>
</Card>
) : (
jefes.map((j) => (
<button
key={j.id}
onClick={() => seleccionarJefe(j)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
>
<div>
<p className="font-medium text-gray-800">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver sus trabajos</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))
)}
</div>
{!loadingTurnos && misTurnos.length > 0 && (
@@ -288,27 +310,24 @@ function TurnosContent() {
</>
)}
{/* STEP: jefe */}
{step === "jefe" && (
<div className="flex flex-col gap-3">
{loadingJefes ? (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
) : jefes.length === 0 ? (
{/* STEP: servicio */}
{step === "servicio" && (
<div className="grid gap-3">
{loadingServicios ? (
<p className="text-center text-sm text-gray-200">Cargando trabajos...</p>
) : servicios.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 text-center">
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
{jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico.
</p>
</Card>
) : (
jefes.map((j) => (
<button
key={j.id}
onClick={() => seleccionarJefe(j)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
>
servicios.map(s => (
<button key={s.id} onClick={() => seleccionarServicio(s)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver disponibilidad</p>
<p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
+5 -5
View File
@@ -9,9 +9,9 @@ interface Turno {
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
comentario: string | null;
kilometros: number;
kilometros: number | null;
tipoServicio: { nombre: string };
auto: { marca: string; modelo: string; patente: string };
auto: { marca: string; modelo: string; patente: string } | null;
cliente: { nombre: string; apellido: string; telefono: string };
}
@@ -81,11 +81,11 @@ export default function JefeDashboard() {
<span className="text-gray-400">Teléfono</span>
<span>{t.cliente.telefono}</span>
<span className="text-gray-400">Auto</span>
<span>{t.auto.marca} {t.auto.modelo}</span>
<span>{t.auto ? `${t.auto.marca} ${t.auto.modelo}` : <em className="text-amber-600">a completar</em>}</span>
<span className="text-gray-400">Patente</span>
<span className="font-mono">{t.auto.patente}</span>
<span className="font-mono">{t.auto?.patente ?? "—"}</span>
<span className="text-gray-400">Kilometraje</span>
<span>{t.kilometros.toLocaleString("es-AR")} km</span>
<span>{t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : <em className="text-amber-600">a completar</em>}</span>
</div>
{t.comentario && (
+23
View File
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
if (pathname === "/jefe") return <>{children}</>;
const tabs = [
{ href: "/jefe/dashboard", label: "Turnos" },
{ href: "/jefe/servicios", label: "Trabajos" },
];
return (
<div className="min-h-screen flex flex-col">
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col py-6 gap-4">
@@ -24,6 +29,24 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
Cerrar sesión
</button>
</div>
<nav className="flex gap-1">
{tabs.map((t) => {
const activo = pathname === t.href;
return (
<button
key={t.href}
onClick={() => router.push(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}
</button>
);
})}
</nav>
<main className="flex-1">{children}</main>
</div>
</div>
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useEffect, useState } from "react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
interface Servicio {
id: string;
nombre: string;
duracionHoras: number;
activo: boolean;
}
export default function JefeServicios() {
const [servicios, setServicios] = useState<Servicio[]>([]);
const [loading, setLoading] = useState(true);
const [nombre, setNombre] = useState("");
const [duracion, setDuracion] = useState("");
const [error, setError] = useState<string | null>(null);
const [guardando, setGuardando] = useState(false);
useEffect(() => { cargar(); }, []);
async function cargar() {
setLoading(true);
const res = await fetch("/api/jefe/servicios");
const data = await res.json();
setServicios(Array.isArray(data) ? data : []);
setLoading(false);
}
async function agregar(e: React.FormEvent) {
e.preventDefault();
setError(null);
setGuardando(true);
const res = await fetch("/api/jefe/servicios", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nombre, duracionHoras: duracion }),
});
setGuardando(false);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d.error ?? "No se pudo guardar el trabajo");
return;
}
setNombre("");
setDuracion("");
cargar();
}
async function toggleActivo(s: Servicio) {
await fetch(`/api/jefe/servicios/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activo: !s.activo }),
});
cargar();
}
async function editarDuracion(s: Servicio) {
const valor = prompt(`Duración de "${s.nombre}" en horas:`, String(s.duracionHoras));
if (valor == null) return;
const res = await fetch(`/api/jefe/servicios/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duracionHoras: valor }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error ?? "No se pudo actualizar");
return;
}
cargar();
}
async function eliminar(s: Servicio) {
if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return;
const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" });
const d = await res.json().catch(() => ({}));
if (d.desactivado)
alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse.");
cargar();
}
return (
<div className="flex flex-col gap-6">
<h2 className="text-xl sm:text-2xl font-bold text-white">Mis trabajos y tiempos</h2>
<Card>
<form onSubmit={agregar} className="flex flex-col gap-3">
<p className="text-sm text-gray-600">
Cargá los trabajos que ofrecés y cuánto tardan. Son los que verá el cliente al reservar por WhatsApp.
</p>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-3 items-end">
<Input
label="Trabajo"
placeholder="Ej: Cambio de aceite"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
/>
<Input
label="Duración (horas)"
type="text"
inputMode="decimal"
placeholder="Ej: 1.5"
value={duracion}
onChange={(e) => setDuracion(e.target.value)}
className="sm:w-40"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div>
<Button type="submit" loading={guardando}>Agregar trabajo</Button>
</div>
</form>
</Card>
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : servicios.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">Todavía no cargaste ningún trabajo.</p>
</Card>
) : (
<div className="flex flex-col gap-3">
{servicios.map((s) => (
<Card key={s.id}>
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<p className={`font-semibold ${s.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{s.nombre}
</p>
<p className="text-sm text-gray-500 mt-0.5">
{s.duracionHoras} {s.duracionHoras === 1 ? "hora" : "horas"}
{!s.activo && " · inactivo"}
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="text-sm" onClick={() => editarDuracion(s)}>
Duración
</Button>
<Button variant="secondary" className="text-sm" onClick={() => toggleActivo(s)}>
{s.activo ? "Desactivar" : "Activar"}
</Button>
<Button variant="danger" className="text-sm" onClick={() => eliminar(s)}>
Eliminar
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
+2 -2
View File
@@ -14,8 +14,8 @@ const geistMono = localFont({
});
export const metadata: Metadata = {
title: "Taller MG",
description: "Sistema de turnos - Taller MG",
title: "Taller Beldor",
description: "Sistema de turnos - Taller Beldor",
};
export default function RootLayout({
+25 -8
View File
@@ -3,9 +3,16 @@ import { InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
/** Texto fijo grisado antes del campo (ej: el prefijo de país del teléfono). */
prefijo?: string;
ayuda?: string;
}
export default function Input({ label, error, className = "", id, ...props }: InputProps) {
export default function Input({ label, error, prefijo, ayuda, className = "", id, ...props }: InputProps) {
const estiloInput = `border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-gray-100 text-gray-900 placeholder-gray-500 ${
error ? "border-red-400" : "border-gray-400"
} ${className}`;
return (
<div className="flex flex-col gap-1">
{label && (
@@ -13,13 +20,23 @@ export default function Input({ label, error, className = "", id, ...props }: In
{label}
</label>
)}
<input
id={id}
className={`border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-gray-100 text-gray-900 placeholder-gray-500 ${
error ? "border-red-400" : "border-gray-400"
} ${className}`}
{...props}
/>
{prefijo ? (
<div
className={`flex items-center rounded-lg border bg-gray-100 focus-within:ring-2 focus-within:ring-blue-500 transition ${
error ? "border-red-400" : "border-gray-400"
}`}
>
<span className="pl-3 pr-1 text-sm text-gray-500 select-none">{prefijo}</span>
<input
id={id}
className={`flex-1 bg-transparent px-1 py-2 text-sm outline-none text-gray-900 placeholder-gray-500 ${className}`}
{...props}
/>
</div>
) : (
<input id={id} className={estiloInput} {...props} />
)}
{ayuda && !error && <p className="text-xs text-gray-500">{ayuda}</p>}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
+8 -2
View File
@@ -28,14 +28,20 @@ export async function crearSesionJefe(jefeId: string): Promise<string> {
}
export async function verificarSesion(
cookieName: string
cookieName: string,
rolEsperado?: "admin" | "cliente" | "jefe"
): Promise<{ sub: string; role: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, SECRET);
return payload as { sub: string; role: string };
const sesion = payload as { sub: string; role: string };
// Los tres tipos de sesión se firman con el mismo secreto, así que un token de
// admin o de jefe puesto en la cookie del cliente pasa la verificación de firma.
// Cuando el rol importa, se comprueba acá.
if (rolEsperado && sesion.role !== rolEsperado) return null;
return sesion;
} catch {
return null;
}
+17 -7
View File
@@ -1,5 +1,13 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
/**
* Cliente de Prisma o el cliente de una transacción en curso. Permite que la
* verificación de disponibilidad corra dentro de la misma transacción que crea
* el turno (ver `crearTurno`), que es lo que evita reservas superpuestas.
*/
type ClienteDb = Prisma.TransactionClient;
const INICIO_H = 8;
const INICIO_M = 30;
const FIN_H = 17;
@@ -53,9 +61,10 @@ function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
async function turnosSolapados(
inicio: Date,
fin: Date,
jefeMecanicoId: string
jefeMecanicoId: string,
db: ClienteDb = prisma
): Promise<number> {
const count = await prisma.turno.count({
const count = await db.turno.count({
where: {
jefeMecanicoId,
estado: { in: ["PENDIENTE", "CONFIRMADO"] },
@@ -65,8 +74,8 @@ async function turnosSolapados(
return count;
}
async function obtenerJefeActivo(jefeMecanicoId: string) {
return prisma.jefeMecanico.findUnique({
async function obtenerJefeActivo(jefeMecanicoId: string, db: ClienteDb = prisma) {
return db.jefeMecanico.findUnique({
where: { id: jefeMecanicoId },
select: { slotsParalelos: true, activo: true },
});
@@ -133,11 +142,12 @@ export async function obtenerSlotsDisponibles(
export async function slotEstaDisponible(
inicio: Date,
fin: Date,
jefeMecanicoId: string
jefeMecanicoId: string,
db: ClienteDb = prisma
): Promise<boolean> {
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
const jefe = await obtenerJefeActivo(jefeMecanicoId);
const jefe = await obtenerJefeActivo(jefeMecanicoId, db);
if (!jefe || !jefe.activo) return false;
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId);
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId, db);
return ocupados < jefe.slotsParalelos;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Prisma, TipoServicio } from "@prisma/client";
/**
* Regla única de qué trabajos ofrece un jefe mecánico.
*
* Los jefes cargan sus propios trabajos desde `/jefe/servicios` (`jefeMecanicoId` = su id),
* y el admin puede cargar trabajos globales del taller (`jefeMecanicoId` = null) que ofrece
* cualquiera. El cliente elige primero el mecánico y después el trabajo, así que la lista
* siempre es: los del jefe + los globales.
*/
export function whereTrabajosDeJefe(jefeMecanicoId: string): Prisma.TipoServicioWhereInput {
return {
activo: true,
OR: [{ jefeMecanicoId }, { jefeMecanicoId: null }],
};
}
/** Un trabajo propio de otro jefe no se puede reservar con este. Los globales sí. */
export function trabajoEsDeJefe(
servicio: Pick<TipoServicio, "jefeMecanicoId">,
jefeMecanicoId: string
): boolean {
return servicio.jefeMecanicoId === null || servicio.jefeMecanicoId === jefeMecanicoId;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Normaliza un identificador de teléfono a solo dígitos (estilo E.164 sin el '+').
* WhatsApp Cloud API entrega el `wa_id` ya así (ej: "5493511234567"), pero lo pasamos
* por acá para que el matcheo sea consistente venga de donde venga.
*/
export function normalizarTelefono(valor: string): string {
return (valor ?? "").replace(/\D/g, "");
}
/**
* Prefijo con el que WhatsApp identifica a los celulares argentinos: 54 (país) + 9 (móvil).
* En el registro web se muestra fijo y el cliente completa el resto, así el número queda
* guardado igual que el `wa_id` que después manda Meta y el bot lo reconoce.
*/
export const PREFIJO_WHATSAPP_AR = "549";
/**
* Parte local del número: característica sin el 0 y abonado sin el 15.
* En Argentina son siempre 10 dígitos (ej: 11 4198-4844, 351 555-4444).
*/
export function esLocalArgentinoValido(local: string): boolean {
const d = normalizarTelefono(local);
// Ninguna característica argentina empieza con 0 (es el prefijo de larga distancia)
// ni con 15 (el viejo prefijo de celular), así que esos casos son error de tipeo.
return d.length === 10 && !d.startsWith("0") && !d.startsWith("15");
}
/** Número local a formato WhatsApp: 1141984844 → 5491141984844. */
export function aFormatoWhatsapp(local: string): string {
return PREFIJO_WHATSAPP_AR + normalizarTelefono(local);
}
/**
* Inverso del anterior: 5491141984844 → 1141984844. Sirve para mostrar el número
* en el formulario (donde el +54 9 es un prefijo fijo), venga de un alta web o de
* una por WhatsApp, que lo guarda con el prefijo puesto.
*/
export function aLocalArgentino(valor: string): string {
const d = normalizarTelefono(valor);
return d.startsWith(PREFIJO_WHATSAPP_AR) ? d.slice(PREFIJO_WHATSAPP_AR.length) : d;
}
+70
View File
@@ -0,0 +1,70 @@
import { prisma } from "./prisma";
import { slotEstaDisponible } from "./scheduling";
import { trabajoEsDeJefe } from "./servicios";
export interface CrearTurnoInput {
clienteId: string;
jefeMecanicoId: string;
tipoServicioId: string;
fechaInicio: Date;
autoId?: string | null;
kilometros?: number | null;
}
export type CrearTurnoResultado =
| { ok: true; turno: Awaited<ReturnType<typeof prisma.turno.create>> }
| { ok: false; error: string; status: number };
/**
* Crea un turno validando servicio/jefe activos y disponibilidad del slot.
* Lo usan tanto el flujo web (con auto+km) como el bot de WhatsApp (sin auto+km).
* La duración del turno la define el TipoServicio.
*/
export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResultado> {
const { clienteId, jefeMecanicoId, tipoServicioId, fechaInicio } = input;
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return { ok: false, error: "Servicio no disponible", status: 404 };
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } });
if (!jefe || !jefe.activo)
return { ok: false, error: "Jefe mecánico no disponible", status: 404 };
// Un trabajo propio de otro jefe no se reserva con éste (los globales sí).
if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
return { ok: false, error: "Ese trabajo no lo hace este mecánico", status: 400 };
const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
// Verificar y crear en una sola transacción, serializando por jefe con un lock
// de Postgres: si no, dos clientes pueden pasar la verificación al mismo tiempo
// y quedar los dos reservados sobre la misma capacidad.
const turno = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${jefeMecanicoId}))`;
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId, tx);
if (!disponible) return null;
return tx.turno.create({
data: {
clienteId,
jefeMecanicoId,
tipoServicioId,
fechaInicio,
fechaFin: fin,
autoId: input.autoId ?? null,
kilometros: input.kilometros ?? null,
},
include: {
auto: true,
tipoServicio: true,
jefeMecanico: { select: { id: true, nombre: true } },
},
});
});
if (!turno) return { ok: false, error: "El horario ya no está disponible", status: 409 };
return { ok: true, turno };
}
+373
View File
@@ -0,0 +1,373 @@
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
import { obtenerSlotsDisponibles } from "./scheduling";
import { crearTurno } from "./turnos";
import { normalizarTelefono } from "./telefono";
import { trabajoEsDeJefe, whereTrabajosDeJefe } from "./servicios";
// ── Tipos de respuesta que el bot le pide al canal (F0 los traduce a la API de Meta) ──
export type BotonBot = { id: string; titulo: string };
export type RespuestaBot =
| { tipo: "texto"; texto: string }
| { tipo: "botones"; texto: string; botones: BotonBot[] };
// ── Estado que se persiste en ConversacionWhatsapp.datos ──
interface OpcionMenu {
letra: string;
id: string;
label: string;
}
interface DatosConv {
nombre?: string;
apellido?: string;
jefeMecanicoId?: string;
jefeNombre?: string;
tipoServicioId?: string;
tipoNombre?: string;
duracionHoras?: number;
slotsOffset?: number;
slotInicio?: string; // ISO del slot elegido
slotLabel?: string;
opciones?: OpcionMenu[]; // opciones mostradas en el paso actual (letra → id)
intentosEmail?: number;
}
const EXPIRA_MIN = 30;
const SLOTS_POR_PAGINA = 8;
const LETRAS = "abcdefghijklmnopqrstuvwxyz";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SI = ["si", "sí", "s", "dale", "ok", "confirmo", "confirmar"];
const NO = ["no", "n", "cancelar", "cancela"];
const MAS = ["mas", "más", "+", "ver mas", "ver más"];
// ── Helpers de presentación ──
function formatSlot(fecha: Date): string {
return fecha.toLocaleString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires",
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function listaConLetras(items: { label: string }[]): { texto: string; opciones: OpcionMenu[] } {
const opciones: OpcionMenu[] = [];
const lineas = items.map((it, i) => {
const letra = LETRAS[i] ?? String(i + 1);
opciones.push({ letra, id: "", label: it.label });
return `${letra}) ${it.label}`;
});
return { texto: lineas.join("\n"), opciones };
}
function elegirOpcion(datos: DatosConv, input: string): OpcionMenu | undefined {
const t = input.trim().toLowerCase();
return (datos.opciones ?? []).find(
(o) => o.id.toLowerCase() === t || o.letra.toLowerCase() === t || o.label.toLowerCase() === t
);
}
// ── Construcción de pasos reutilizables ──
async function pasoElegirMecanico(): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const jefes = await prisma.jefeMecanico.findMany({
where: { activo: true },
orderBy: { nombre: "asc" },
select: { id: true, nombre: true },
});
if (jefes.length === 0)
return {
resp: [{ tipo: "texto", texto: "No hay mecánicos disponibles en este momento. Probá más tarde." }],
datos: {},
paso: "INICIO",
};
const opciones: OpcionMenu[] = jefes.map((j, i) => ({ letra: String(i + 1), id: j.id, label: j.nombre }));
const botones: BotonBot[] = jefes.slice(0, 3).map((j) => ({ id: j.id, titulo: j.nombre.slice(0, 20) }));
return {
resp: [{ tipo: "botones", texto: "¿Con qué mecánico querés atenderte?", botones }],
datos: { opciones },
paso: "ELEGIR_MECANICO",
};
}
async function pasoElegirTrabajo(datos: DatosConv): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const servicios = await prisma.tipoServicio.findMany({
where: whereTrabajosDeJefe(datos.jefeMecanicoId!),
orderBy: { nombre: "asc" },
});
if (servicios.length === 0) {
const m = await pasoElegirMecanico();
return {
resp: [{ tipo: "texto", texto: `${datos.jefeNombre} todavía no cargó trabajos. Elegí otro mecánico.` }, ...m.resp],
datos: m.datos,
paso: m.paso,
};
}
const { texto, opciones } = listaConLetras(
servicios.map((s) => ({ label: `${s.nombre} (${s.duracionHoras}h)` }))
);
// Guardar el id real de cada trabajo en las opciones
servicios.forEach((s, i) => (opciones[i].id = s.id));
return {
resp: [
{
tipo: "texto",
texto: `Trabajos de ${datos.jefeNombre}:\n\n${texto}\n\nRespondé con la letra de la opción.`,
},
],
datos: { ...datos, opciones },
paso: "ELEGIR_TRABAJO",
};
}
async function pasoMostrarHorarios(
datos: DatosConv,
offset: number
): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const slots = await obtenerSlotsDisponibles(datos.jefeMecanicoId!, datos.duracionHoras ?? 1, 30, 200);
if (slots.length === 0)
return {
resp: [
{ tipo: "texto", texto: "No hay horarios disponibles en los próximos días. Probá más tarde. 🙏" },
],
datos: {},
paso: "INICIO",
};
const off = offset >= slots.length ? 0 : offset;
const pagina = slots.slice(off, off + SLOTS_POR_PAGINA);
const opciones: OpcionMenu[] = pagina.map((s, i) => ({
letra: LETRAS[i] ?? String(i + 1),
id: s.fechaInicio.toISOString(),
label: formatSlot(s.fechaInicio),
}));
const lista = opciones.map((o) => `${o.letra}) ${o.label}`).join("\n");
const hayMas = off + SLOTS_POR_PAGINA < slots.length;
let texto = `Horarios disponibles con ${datos.jefeNombre} para ${datos.tipoNombre}:\n\n${lista}\n\nRespondé con la letra.`;
if (hayMas) texto += `\nEscribí *más* para ver otros horarios.`;
return {
resp: [{ tipo: "texto", texto }],
datos: { ...datos, opciones, slotsOffset: off },
paso: "ELEGIR_HORARIO",
};
}
// ── Persistencia ──
async function guardar(
convId: string,
paso: string,
datos: DatosConv,
resp: RespuestaBot[],
clienteId?: string | null
): Promise<RespuestaBot[]> {
await prisma.conversacionWhatsapp.update({
where: { id: convId },
data: {
paso,
datos: datos as unknown as Prisma.InputJsonValue,
...(clienteId !== undefined && { clienteId }),
},
});
return resp;
}
const BIENVENIDA = "¡Hola! 👋 Soy el asistente del *Taller Beldor*. Te ayudo a sacar un turno.";
// ── Entrada principal ──
export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Promise<RespuestaBot[]> {
const telefono = normalizarTelefono(telefonoRaw);
const texto = (textoRaw ?? "").trim();
const lower = texto.toLowerCase();
const conv = await prisma.conversacionWhatsapp.upsert({
where: { telefono },
create: { telefono },
update: {},
});
let paso = conv.paso;
let datos = (conv.datos ?? {}) as DatosConv;
// Expiración: si la charla quedó a mitad y pasó el tiempo, se reinicia.
const vencida = Date.now() - new Date(conv.updatedAt).getTime() > EXPIRA_MIN * 60 * 1000;
if (paso !== "INICIO" && vencida) {
paso = "INICIO";
datos = {};
}
// Comando global: cancelar
if (NO.includes(lower) && paso !== "CONFIRMAR" && paso !== "INICIO") {
return guardar(conv.id, "INICIO", {}, [
{ tipo: "texto", texto: "Listo, cancelé la operación. Escribime cuando quieras para empezar de nuevo." },
]);
}
switch (paso) {
case "INICIO": {
const cliente = await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });
if (cliente) {
if (cliente.email) {
return guardar(conv.id, "VERIFICAR_EMAIL", { intentosEmail: 0 }, [
{ tipo: "texto", texto: BIENVENIDA },
{ tipo: "texto", texto: "Para confirmar tu identidad, escribí el *email* que tenés registrado." },
], cliente.id);
}
// Cliente sin email registrado → no hay con qué verificar, se avanza directo.
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: BIENVENIDA }, ...m.resp], cliente.id);
}
// Cliente nuevo → alta.
return guardar(conv.id, "ALTA_NOMBRE", {}, [
{ tipo: "texto", texto: BIENVENIDA },
{ tipo: "texto", texto: "No te tengo registrado. ¿Cuál es tu *nombre*?" },
], null);
}
case "VERIFICAR_EMAIL": {
const cliente = conv.clienteId
? await prisma.cliente.findUnique({ where: { id: conv.clienteId } })
: await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });
if (cliente?.email && lower === cliente.email.toLowerCase()) {
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, m.resp, cliente.id);
}
const intentos = (datos.intentosEmail ?? 0) + 1;
if (intentos >= 3)
return guardar(conv.id, "INICIO", {}, [
{ tipo: "texto", texto: "El email no coincide. Por seguridad cerramos acá; comunicate con el taller. 🙏" },
]);
return guardar(conv.id, "VERIFICAR_EMAIL", { ...datos, intentosEmail: intentos }, [
{ tipo: "texto", texto: "Ese email no coincide con el registrado. Probá de nuevo." },
]);
}
case "ALTA_NOMBRE": {
if (!texto) return guardar(conv.id, "ALTA_NOMBRE", datos, [{ tipo: "texto", texto: "Decime tu *nombre*, por favor." }]);
return guardar(conv.id, "ALTA_APELLIDO", { ...datos, nombre: texto }, [
{ tipo: "texto", texto: `Gracias, ${texto}. ¿Cuál es tu *apellido*?` },
]);
}
case "ALTA_APELLIDO": {
if (!texto) return guardar(conv.id, "ALTA_APELLIDO", datos, [{ tipo: "texto", texto: "Decime tu *apellido*, por favor." }]);
return guardar(conv.id, "ALTA_EMAIL", { ...datos, apellido: texto }, [
{ tipo: "texto", texto: "Por último, ¿cuál es tu *email*?" },
]);
}
case "ALTA_EMAIL": {
if (!EMAIL_RE.test(texto))
return guardar(conv.id, "ALTA_EMAIL", datos, [{ tipo: "texto", texto: "Ese email no parece válido. Escribilo de nuevo." }]);
const existente = await prisma.cliente.findUnique({ where: { email: texto.toLowerCase() } });
if (existente)
return guardar(conv.id, "ALTA_EMAIL", datos, [
{ tipo: "texto", texto: "Ese email ya está registrado con otro número. Escribí otro o comunicate con el taller." },
]);
const cliente = await prisma.cliente.create({
data: {
nombre: datos.nombre ?? "",
apellido: datos.apellido ?? "",
email: texto.toLowerCase(),
telefono,
telefonoNorm: telefono,
},
});
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "¡Listo, quedaste registrado! ✅" }, ...m.resp], cliente.id);
}
case "ELEGIR_MECANICO": {
const op = elegirOpcion(datos, texto);
if (!op) {
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "No entendí. Elegí un mecánico:" }, ...m.resp]);
}
const m = await pasoElegirTrabajo({ jefeMecanicoId: op.id, jefeNombre: op.label });
return guardar(conv.id, m.paso, m.datos, m.resp);
}
case "ELEGIR_TRABAJO": {
const op = elegirOpcion(datos, texto);
if (!op)
return guardar(conv.id, "ELEGIR_TRABAJO", datos, [{ tipo: "texto", texto: "No entendí. Respondé con la letra del trabajo." }]);
const servicio = await prisma.tipoServicio.findUnique({ where: { id: op.id } });
if (!servicio || !servicio.activo || !trabajoEsDeJefe(servicio, datos.jefeMecanicoId!)) {
const m = await pasoElegirTrabajo(datos);
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "Ese trabajo ya no está disponible." }, ...m.resp]);
}
const nuevosDatos: DatosConv = {
jefeMecanicoId: datos.jefeMecanicoId,
jefeNombre: datos.jefeNombre,
tipoServicioId: servicio.id,
tipoNombre: servicio.nombre,
duracionHoras: servicio.duracionHoras,
};
const m = await pasoMostrarHorarios(nuevosDatos, 0);
return guardar(conv.id, m.paso, m.datos, m.resp);
}
case "ELEGIR_HORARIO": {
if (MAS.includes(lower)) {
const m = await pasoMostrarHorarios(datos, (datos.slotsOffset ?? 0) + SLOTS_POR_PAGINA);
return guardar(conv.id, m.paso, m.datos, m.resp);
}
const op = elegirOpcion(datos, texto);
if (!op)
return guardar(conv.id, "ELEGIR_HORARIO", datos, [
{ tipo: "texto", texto: "No entendí. Respondé con la letra del horario (o *más* para ver otros)." },
]);
const resumen = `Confirmás este turno?\n\n🔧 Mecánico: ${datos.jefeNombre}\n🛠️ Trabajo: ${datos.tipoNombre}\n📅 ${op.label}\n\nRespondé *sí* o *no*.`;
return guardar(conv.id, "CONFIRMAR", { ...datos, slotInicio: op.id, slotLabel: op.label }, [
{ tipo: "texto", texto: resumen },
]);
}
case "CONFIRMAR": {
if (NO.includes(lower))
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: "Listo, no reservé nada. Escribime cuando quieras. 👋" }]);
if (!SI.includes(lower))
return guardar(conv.id, "CONFIRMAR", datos, [{ tipo: "texto", texto: "Respondé *sí* para confirmar o *no* para cancelar." }]);
const res = await crearTurno({
clienteId: conv.clienteId!,
jefeMecanicoId: datos.jefeMecanicoId!,
tipoServicioId: datos.tipoServicioId!,
fechaInicio: new Date(datos.slotInicio!),
});
if (!res.ok) {
if (res.status === 409) {
const m = await pasoMostrarHorarios(datos, 0);
return guardar(conv.id, m.paso, m.datos, [
{ tipo: "texto", texto: "Justo te ganaron ese horario. 😕 Te muestro otros:" },
...m.resp,
]);
}
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: `No pude reservar: ${res.error}. Probá de nuevo más tarde.` }]);
}
return guardar(conv.id, "INICIO", {}, [
{
tipo: "texto",
texto: `¡Turno confirmado! ✅\n\n🔧 ${datos.jefeNombre}\n🛠️ ${datos.tipoNombre}\n📅 ${datos.slotLabel}\n\nTe esperamos. Si querés otro turno, escribime.`,
},
]);
}
default:
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: BIENVENIDA }]);
}
}
+181
View File
@@ -0,0 +1,181 @@
import crypto from "crypto";
import type { RespuestaBot } from "./whatsapp-bot";
/**
* Cliente de la WhatsApp Business Cloud API (Graph API de Meta).
* Traduce las `RespuestaBot` que emite la máquina de conversación a los payloads
* que espera Meta. El bot no sabe nada de Meta: todo el acople vive acá.
*/
const GRAPH_VERSION = "v21.0";
// Límites de la API de Meta — si nos pasamos, el mensaje se rechaza entero.
const MAX_TEXTO = 4096;
const MAX_BODY_INTERACTIVO = 1024;
const MAX_BOTONES = 3;
const MAX_TITULO_BOTON = 20;
function credenciales() {
const token = process.env.WHATSAPP_TOKEN;
const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID;
if (!token || !phoneNumberId) return null;
return { token, phoneNumberId };
}
export function whatsappConfigurado(): boolean {
return credenciales() !== null;
}
/** Valida la firma X-Hub-Signature-256 del webhook contra el cuerpo crudo. */
export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): boolean {
const appSecret = process.env.WHATSAPP_APP_SECRET;
if (!appSecret) return false;
if (!firmaHeader?.startsWith("sha256=")) return false;
const esperada = crypto.createHmac("sha256", appSecret).update(cuerpoCrudo, "utf8").digest("hex");
const recibida = firmaHeader.slice("sha256=".length);
// Comparación de tiempo constante; timingSafeEqual explota si difieren los largos.
const a = Buffer.from(esperada, "utf8");
const b = Buffer.from(recibida, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Meta tira 429 (rate limit) y 5xx transitorios; ahí conviene reintentar en vez de
// perder la respuesta al cliente. Un 4xx que no sea 429 es error nuestro: no se
// reintenta porque va a fallar igual.
const INTENTOS = 3;
const ESPERA_BASE_MS = 500;
function esperar(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function postMensaje(payload: Record<string, unknown>): Promise<void> {
const cred = credenciales();
if (!cred) {
console.error("[whatsapp] falta WHATSAPP_TOKEN o WHATSAPP_PHONE_NUMBER_ID; no se envía nada");
return;
}
const url = `https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`;
const init: RequestInit = {
method: "POST",
headers: {
Authorization: `Bearer ${cred.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),
};
for (let intento = 1; intento <= INTENTOS; intento++) {
let status: number;
let detalle = "";
try {
const res = await fetch(url, init);
if (res.ok) return;
status = res.status;
detalle = await res.text().catch(() => "");
} catch (e) {
// Falla de red: mismo tratamiento que un 5xx.
status = 0;
detalle = String(e);
}
const reintentable = status === 0 || status === 429 || status >= 500;
if (!reintentable || intento === INTENTOS) {
console.error(`[whatsapp] error ${status} al enviar mensaje (intento ${intento}): ${detalle}`);
return;
}
// Backoff exponencial con jitter para no sincronizar los reintentos.
const espera = ESPERA_BASE_MS * 2 ** (intento - 1) + Math.floor(Math.random() * 250);
console.warn(`[whatsapp] error ${status}, reintento ${intento + 1}/${INTENTOS} en ${espera}ms`);
await esperar(espera);
}
}
async function enviarTexto(telefono: string, texto: string): Promise<void> {
await postMensaje({
to: telefono,
type: "text",
text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },
});
}
async function enviarBotones(
telefono: string,
texto: string,
botones: { id: string; titulo: string }[]
): Promise<void> {
// Meta banca 3 botones como máximo. La máquina de conversación siempre deja las
// mismas opciones disponibles por número/letra, así que recortar no rompe el flujo.
const recortados = botones.slice(0, MAX_BOTONES).map((b) => ({
type: "reply",
reply: { id: b.id, title: b.titulo.slice(0, MAX_TITULO_BOTON) },
}));
if (recortados.length === 0) {
await enviarTexto(telefono, texto);
return;
}
await postMensaje({
to: telefono,
type: "interactive",
interactive: {
type: "button",
body: { text: texto.slice(0, MAX_BODY_INTERACTIVO) },
action: { buttons: recortados },
},
});
}
/** Envía en orden las respuestas del bot (secuencial: WhatsApp respeta el orden de llegada). */
export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise<void> {
for (const r of respuestas) {
if (r.tipo === "botones") await enviarBotones(telefono, r.texto, r.botones);
else await enviarTexto(telefono, r.texto);
}
}
/** Marca el mensaje como leído (los tildes azules). Best-effort: si falla, no importa. */
export async function marcarLeido(messageId: string): Promise<void> {
await postMensaje({ status: "read", message_id: messageId });
}
// ── Entrada: payload del webhook → mensajes ──
// Forma (parcial) de lo que manda Meta; solo lo que consumimos.
export type MensajeMeta = {
from?: string;
id?: string;
type?: string;
text?: { body?: string };
button?: { text?: string };
interactive?: { button_reply?: { id?: string }; list_reply?: { id?: string } };
};
export type PayloadWebhook = {
entry?: { changes?: { value?: { messages?: MensajeMeta[] } }[] }[];
};
/** Aplana el payload del webhook a la lista de mensajes entrantes (ignora `statuses`). */
export function mensajesDelPayload(body: PayloadWebhook): MensajeMeta[] {
const salida: MensajeMeta[] = [];
for (const entry of body?.entry ?? [])
for (const change of entry?.changes ?? []) salida.push(...(change?.value?.messages ?? []));
return salida;
}
/** Saca el texto de un mensaje según su tipo. `null` = tipo que el bot no maneja. */
export function textoDeMensaje(mensaje: MensajeMeta): string | null {
if (mensaje.type === "text") return mensaje.text?.body ?? "";
if (mensaje.type === "interactive") {
const i = mensaje.interactive ?? {};
// El id del botón/opción es el que puso el bot en las opciones → matchea directo.
return i.button_reply?.id ?? i.list_reply?.id ?? null;
}
if (mensaje.type === "button") return mensaje.button?.text ?? null;
return null;
}
+9 -6
View File
@@ -3,12 +3,14 @@ import { jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET || "dev-secret");
async function verifyCookie(req: NextRequest, cookieName: string) {
// Las tres sesiones se firman con el mismo secreto, así que además de la firma hay
// que mirar el rol: si no, un token de jefe en la cookie del cliente pasa.
async function verifyCookie(req: NextRequest, cookieName: string, rol: "admin" | "cliente" | "jefe") {
const token = req.cookies.get(cookieName)?.value;
if (!token) return false;
try {
await jwtVerify(token, SECRET);
return true;
const { payload } = await jwtVerify(token, SECRET);
return (payload as { role?: string }).role === rol;
} catch {
return false;
}
@@ -19,8 +21,9 @@ export async function middleware(req: NextRequest) {
if (pathname.startsWith("/cliente/dashboard") ||
pathname.startsWith("/cliente/historial") ||
pathname.startsWith("/cliente/perfil") ||
pathname.startsWith("/cliente/turnos")) {
const ok = await verifyCookie(req, "cliente_session");
const ok = await verifyCookie(req, "cliente_session", "cliente");
if (!ok) return NextResponse.redirect(new URL("/cliente", req.url));
}
@@ -30,12 +33,12 @@ export async function middleware(req: NextRequest) {
pathname.startsWith("/admin/servicios") ||
pathname.startsWith("/admin/jefes") ||
pathname.startsWith("/admin/clientes")) {
const ok = await verifyCookie(req, "admin_session");
const ok = await verifyCookie(req, "admin_session", "admin");
if (!ok) return NextResponse.redirect(new URL("/admin", req.url));
}
if (pathname.startsWith("/jefe/dashboard")) {
const ok = await verifyCookie(req, "jefe_session");
const ok = await verifyCookie(req, "jefe_session", "jefe");
if (!ok) return NextResponse.redirect(new URL("/jefe", req.url));
}