16 Commits

Author SHA1 Message Date
Beldor a356c02eea docs: lo aprendido en el alta de Taller Beldor
Cuatro preparativos previos (telefono confirmado, nombre del negocio que
coincida con el sitio, sitio con contenido publico, politica de
privacidad), el costo de reintentar a ciegas, la segunda suscripcion
subscribed_apps, la diferencia entre verificar y conectar un numero, y
la revision de cuenta que bloquea el registro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 22:18:05 -03:00
Beldor adab994214 Merge main en docs/alta-cliente 2026-10-19 22:16:50 -03:00
Beldor 6d802ef069 Merge: nombrar Taller Beldor el sitio y el saludo del bot
Deploy / deploy (push) Successful in 5m53s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 17:18:33 -03:00
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
Beldor 95ccf695c1 docs: manual de alta para cada cliente nuevo
Que pedirle al taller, que hace el cliente y que hacemos nosotros para
poner en marcha el bot. Recoge los tropiezos del alta de Taller Beldor:
el SMS de verificacion, el token cortado y los placeholders sin
reemplazar en el .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 16:33:39 -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
32 changed files with 737 additions and 54 deletions
+7
View File
@@ -33,6 +33,13 @@ services:
GOOGLE_CLIENT_EMAIL: ${GOOGLE_CLIENT_EMAIL} GOOGLE_CLIENT_EMAIL: ${GOOGLE_CLIENT_EMAIL}
GOOGLE_SHEET_ID: ${GOOGLE_SHEET_ID} GOOGLE_SHEET_ID: ${GOOGLE_SHEET_ID}
GOOGLE_PRIVATE_KEY: ${GOOGLE_PRIVATE_KEY} 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: ports:
- "3004:3000" - "3004:3000"
+237
View File
@@ -0,0 +1,237 @@
# Alta de un cliente nuevo
Qué hay que pedirle a cada taller y qué hacemos nosotros, para poner en marcha el bot de
reservas por WhatsApp. El detalle técnico de las piezas está en [whatsapp.md](whatsapp.md);
acá está el orden operativo y quién hace cada cosa.
Cada cliente necesita **su propia** cuenta de Meta, su propia app y su propio número.
Nada se comparte entre clientes: ni el token, ni el App Secret, ni el número.
`Taller Beldor` es nuestro ambiente de pruebas y no sirve como cuenta productiva de nadie.
Tiempo realista: **varios días**. La configuración activa lleva una tarde, pero Meta mete
esperas que no se pueden apurar: la revisión de la cuenta, la aprobación del Display Name y
la liberación del número de WhatsApp. Planificar el alta con margen, nunca para el mismo día.
> **Lo que más caro sale es equivocarse en el orden.** Varios pasos dependen de otros de
> forma no evidente, y reintentarlos deja basura que después bloquea. Las secciones están en
> el orden en que hay que hacerlas.
---
## 0. Antes de tocar nada: los cuatro preparativos
Estos cuatro se resuelven **antes** de abrir el panel de Meta. Saltearlos es lo que nos costó
una jornada entera en el alta de Taller Beldor.
1. **El teléfono del titular tiene que estar confirmado en el Centro de cuentas.**
No alcanza con tenerlo a mano: Meta exige que ya esté confirmado antes de dejar verificar
la cuenta de desarrollador, y el mensaje de error no lo dice así.
2. **El nombre del negocio y el del sitio web tienen que coincidir.**
Meta compara el nombre declarado contra el `<title>` del sitio que se declara como
respaldo. Si no coinciden, rechaza el alta con el error de política `#2494070`, que no
explica la causa.
3. **El sitio tiene que servir contenido público.** Una raíz que redirige al login no sirve:
el verificador de Meta ve una pantalla vacía. Hace falta al menos una home con el nombre
del taller y qué hace.
4. **Una política de privacidad en una URL pública.** Es requisito para publicar la app, y
sin publicar la app Meta no entrega datos de producción al webhook.
---
## 1. Lo que le pedimos al cliente antes de empezar
Conviene mandarle esta lista completa de entrada, porque casi todo depende de él.
| Qué | Para qué | Cuidado |
|---|---|---|
| Quién va a ser **titular** de la cuenta de Meta | Todo cuelga de esa cuenta | Si es el dueño, mejor; si es un empleado y se va, se complica |
| **Cuenta de Facebook** de esa persona, con sesión iniciada | Es la puerta de entrada a todo | — |
| **Celular con SMS** para verificar esa cuenta | Meta exige verificar la cuenta de desarrollador | **El número tiene que estar confirmado en el Centro de cuentas ANTES de empezar** |
| **Nombre público exacto** del taller | Nombre del portfolio comercial y base del Display Name | Sin caracteres especiales |
| **Email del negocio** | Contacto del portfolio y de la app | Tiene que poder recibir el mail de verificación |
| **Número de WhatsApp** que va a atender | Es el número del bot | **No puede estar activo en WhatsApp normal ni Business** (ver punto 5) |
| **Jefes mecánicos** con nombre | El bot los ofrece como botones | Más de 3 → los primeros 3 son botón, el resto se elige por número |
| **Tipos de servicio** y duración | El bot los ofrece después del mecánico | Pueden ser globales o propios de cada jefe |
| **Horarios de atención** | Cálculo de los turnos disponibles | — |
## 2. Cuentas de Meta — lo hace el cliente, nosotros guiamos
Son actos que quedan a nombre del cliente: aceptan condiciones legales y crean cuentas.
**Nosotros no los hacemos por él**, aunque estemos compartiendo pantalla.
1. **Confirmar el teléfono** en el Centro de cuentas → Perfiles y datos personales →
Información de contacto → Agregar número → **Confirmar número** con el código del SMS.
2. **Portfolio comercial** en `business.facebook.com`: nombre del negocio, su nombre, email.
3. **Cuenta de desarrollador** en `developers.facebook.com` → "Empezar". Cuatro pasos:
Register (acepta condiciones) → Verify account (el SMS) → Contact info → About you.
4. **Crear la app**: tipo Business, caso de uso **"Conectarte con los clientes a través de
WhatsApp"**, vinculada al portfolio del paso 2.
El **nombre de la app no puede contener "WhatsApp", "Facebook" ni "Insta"**: Meta los
rechaza. Es un nombre interno, no el que ven los clientes.
> **El SMS es el punto donde más veces se traba el alta.** Si no llega: verificar que el
> número ya esté confirmado en el Centro de cuentas (es un SMS distinto del de la cuenta de
> desarrollador), revisar la bandeja de spam del teléfono, y **no reintentar muchas veces
> seguidas** — Meta deja de entregar los códigos por un rato sin avisar. Si pasó eso,
> esperar unas horas y pedir uno solo.
### Reintentar tiene costo: cada intento fallido deja una WABA huérfana
El asistente de alta **crea la cuenta de WhatsApp Business antes de validar el formulario**.
Si el alta falla después de ese punto, la cuenta queda igual, vacía y contando para el cupo.
Un portfolio sin verificar admite pocas, así que a los pocos reintentos aparece el error
`#2494067` ("Se alcanzó el límite de cuentas de WhatsApp Business").
En el alta de Taller Beldor quedaron **seis cuentas, cinco de ellas basura**, y hubo que
borrarlas a mano para poder seguir. Conclusión práctica: **no reintentar a ciegas**. Si el
alta falla, averiguar la causa antes del siguiente intento, y revisar después en
Business Suite → Cuentas de WhatsApp si quedaron cuentas vacías para borrar.
### El campo "Sitio web" es obligatorio
Figura como opcional en algunas pantallas pero el formulario no deja avanzar sin él. Y el
sitio que se declare es el que Meta va a comparar contra el nombre del negocio (ver punto 0).
## 3. Credenciales — las saca el cliente, nosotros no las tocamos
Las cuatro variables del `.env`. **Nunca pasan por chat, mail ni WhatsApp**: el cliente las
copia de la pantalla de Meta y las pega directo en el `.env` del servidor, o nos da acceso
al servidor y las carga él ahí.
| Variable | De dónde sale | Forma esperada |
|---|---|---|
| `WHATSAPP_PHONE_NUMBER_ID` | Caso de uso WhatsApp → Paso 1 "Pruébalo" | Numérico, ~16 dígitos. **No es el teléfono** |
| `WHATSAPP_TOKEN` | Business Settings → Usuarios del sistema → Generar token | **Empieza con `EAA`**, ~200 chars |
| `WHATSAPP_VERIFY_TOKEN` | Lo inventamos nosotros | Alfanumérico, sin símbolos ni espacios |
| `WHATSAPP_APP_SECRET` | App → Configuración → Básica → "Mostrar" | **32 caracteres hexadecimales** |
**System user** (de donde sale el token):
- Crear uno por cliente, por ejemplo `taller-bot`, rol Employee.
- Asignarle **dos activos**: la app (permiso *Desarrollar app*) y la cuenta de WhatsApp
(permisos *Mensajes* y *Números de teléfono — ver y administrar*).
- Generar token con caducidad **Nunca** y los permisos `whatsapp_business_messaging` y
`whatsapp_business_management`.
- **El token se muestra una sola vez.** Tener el `.env` abierto antes de apretar el botón.
Errores que ya nos pasaron y conviene chequear:
- Token **cortado al copiar**: si no empieza con `EAA`, está incompleto.
- Placeholders sin reemplazar: que no queden `<...>` en el `.env`.
- `APP_SECRET` que no tiene 32 hex: quedó truncado.
- Sin `APP_SECRET` correcto, **el webhook rechaza todo con 403** y no entra ningún mensaje.
## 4. Lo que hacemos nosotros
1. Desplegar la app para ese cliente con su dominio propio y certificado válido.
Meta **exige HTTPS** para el webhook.
2. Cargar las 4 variables en el `.env` de ese despliegue y `docker compose up -d`.
El `.env` no está en el repo: un `git push` no lo toca.
3. Verificar el webhook **antes** de darlo de alta en Meta:
```bash
curl "https://<dominio-del-cliente>/api/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=<VERIFY_TOKEN>&hub.challenge=12345"
```
Tiene que devolver exactamente `12345` en texto plano. Si devuelve 403, la variable no
llegó al contenedor.
4. Verificar que el token funciona contra la Graph API:
```bash
curl "https://graph.facebook.com/v21.0/<PHONE_NUMBER_ID>?fields=display_phone_number,verified_name" \
-H "Authorization: Bearer <TOKEN>"
```
5. Dar de alta el webhook en Meta: **Paso 2 → Configurar webhooks**, con la Callback URL del
cliente y el mismo verify token. Meta suscribe **`messages`** solo al guardar; verificar
que el aviso aparezca, porque sin esa suscripción no llega ningún mensaje.
6. **Suscribir la app a la WABA.** Es una segunda suscripción, distinta de la anterior y
fácil de pasar por alto: el campo `messages` se suscribe a nivel app, pero además la app
tiene que estar asociada a esa cuenta de WhatsApp. Si falta, Meta recibe los mensajes y
los entrega a su propia app de pruebas, no al webhook del cliente.
```bash
curl -X POST "https://graph.facebook.com/v21.0/<WABA_ID>/subscribed_apps" \
-H "Authorization: Bearer <TOKEN>"
```
Verificar que el nombre de la app aparezca al consultar ese mismo endpoint con GET.
**Ojo:** si se crea una WABA nueva, hay que rehacer esta suscripción — es por WABA.
7. Cargar los datos del taller: jefes mecánicos, tipos de servicio y horarios.
8. Probar punta a punta, mirando `docker logs -f taller_app`.
El endpoint **solo escribe en el log cuando algo falla**, así que un mensaje que entra
bien es silencioso: la confirmación real es que aparezcan filas en
`MensajeWhatsappProcesado` y `ConversacionWhatsapp`.
> **Los números de prueba de Meta no sirven para probar el bot.** El que regala el panel
> queda en `status: PENDING`, no se le puede escribir, no envía (`133010 Account not
> registered`) y no acepta `/register`. Sirve para los ejemplos de curl de la documentación,
> nada más. La prueba punta a punta necesita el número real ya conectado.
## 5. Migrar el número real del taller
El último paso, el que más fricción genera con el cliente y donde está la espera más larga.
- El número **no puede estar activo en la app de WhatsApp normal o Business**. Hay que
**borrar esa cuenta** antes de migrarlo a Cloud API. Se pierde el historial de chats.
- Avisarle al cliente con tiempo: si el taller venía atendiendo por ese número, va a querer
elegir el momento.
- Después de liberarlo, Meta tarda **hasta 3 minutos** en actualizar el estado. Si al
cargarlo dice que sigue registrado, esperar y reintentar: no falló el borrado.
### Verificar el número NO es lo mismo que conectarlo
Son dos cosas distintas y el número tiene un campo para cada una:
| Campo | Qué significa | Cómo se consigue |
|---|---|---|
| `code_verification_status: VERIFIED` | El cliente probó que la línea es suya | Código por SMS o llamada |
| `status: CONNECTED` | El número está operativo sobre Cloud API | Lo aprovisiona Meta |
**Un número en `PENDING` no envía ni recibe, y ni siquiera existe en WhatsApp**: si se
intenta escribirle desde otro teléfono, WhatsApp solo ofrece "Invitar". Entre que se borra la
cuenta común y que Meta lo conecta, el número queda en tierra de nadie. Es el estado normal
durante la espera, no un error de configuración.
### La revisión de la cuenta bloquea todo, y no se puede apurar
Mientras la cuenta de WhatsApp Business figure **`Pending review`**, Meta deshabilita el
botón "Registrar" del Paso 2 con este mensaje:
> "Number registration and webhook subscription are unavailable for this account now."
No hay nada que configurar mientras tanto. Es el cuello de botella real del alta: **planificar
el alta de cada cliente contando con esta espera**, y no prometerle una fecha hasta que la
cuenta salga de revisión.
- El **Display Name** necesita aprobación, pero suele salir sin revisión
(`name_status: AVAILABLE_WITHOUT_REVIEW`) si coincide con el nombre del negocio.
- La **Business Verification** se saltea mientras el cliente esté abajo de 1000 usuarios
únicos cada 24hs. Para un taller eso es muchísimo, así que en la práctica no aplica.
## 6. Costos a aclarar antes de vender
- El flujo del bot ocurre **dentro de la ventana de 24hs que abre el cliente final**, así que
**no usa plantillas aprobadas y no tiene costo por conversación**.
- Si en algún momento se quisiera que el taller **inicie** la conversación (recordatorios de
turno, por ejemplo), eso **sí** requiere plantillas aprobadas y **sí** tiene costo.
Hoy el bot no lo hace.
## 7. Manejo de credenciales
Vale para todos los clientes y no es negociable:
- **Las credenciales no viajan por chat, mail ni WhatsApp.** Van de la pantalla de Meta al
`.env` del servidor, directo. Un token pegado en una conversación queda registrado ahí para
siempre y hay que revocarlo y regenerarlo.
- **El token de System User se muestra una sola vez.** Tener el `.env` abierto antes.
- Para verificar sin exponer valores, chequear **largo y prefijo** en vez de imprimirlos:
```bash
docker exec <contenedor> sh -c 'v=$(printenv WHATSAPP_TOKEN); echo "${#v} chars"'
```
- **Cuidado con los mensajes de error de la Graph API**: cuando un token es inválido, la
respuesta lo devuelve completo en el campo `message`. No volcar esas respuestas crudas en
un log compartido ni en una conversación.
- El PIN de verificación en dos pasos del número lo elige el cliente y conviene que lo anote:
Meta lo pide si alguna vez hay que re-registrar el número.
+3 -1
View File
@@ -35,7 +35,9 @@ puede confiar en el payload).
Para el token definitivo: Business Settings → System Users → crear usuario → Generate Token Para el token definitivo: Business Settings → System Users → crear usuario → Generate Token
con permisos `whatsapp_business_messaging` y `whatsapp_business_management`. con permisos `whatsapp_business_messaging` y `whatsapp_business_management`.
4. **App → Settings → Basic → App Secret** → `WHATSAPP_APP_SECRET`. 4. **App → Settings → Basic → App Secret** → `WHATSAPP_APP_SECRET`.
5. Cargar las 4 variables en el `.env` de producción y redeployar. 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**: 6. **WhatsApp → Configuration → Webhook**:
- Callback URL: `https://taller.beldo.duckdns.org/api/whatsapp/webhook` - Callback URL: `https://taller.beldo.duckdns.org/api/whatsapp/webhook`
- Verify token: el mismo `WHATSAPP_VERIFY_TOKEN` - Verify token: el mismo `WHATSAPP_VERIFY_TOKEN`
+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);
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); 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"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
@@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
} }
export async function POST(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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, descripcion, observaciones, fechaRealizado } = await req.json(); 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"; import bcrypt from "bcryptjs";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
+2 -2
View File
@@ -4,7 +4,7 @@ import { verificarSesion } from "@/lib/auth-admin";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
export async function GET() { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefes = await prisma.jefeMecanico.findMany({ const jefes = await prisma.jefeMecanico.findMany({
@@ -15,7 +15,7 @@ export async function GET() {
} }
export async function POST(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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, email, password, slotsParalelos } = await req.json(); 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"; import { verificarSesion } from "@/lib/auth-admin";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
+2 -2
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET() { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({ orderBy: { nombre: "asc" } }); const servicios = await prisma.tipoServicio.findMany({ orderBy: { nombre: "asc" } });
@@ -11,7 +11,7 @@ export async function GET() {
} }
export async function POST(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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json(); const { nombre, duracionHoras } = await req.json();
+1 -1
View File
@@ -4,7 +4,7 @@ import { verificarSesion } from "@/lib/auth-admin";
import { agregarFilaSheet } from "@/lib/google-sheets"; import { agregarFilaSheet } from "@/lib/google-sheets";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); 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"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET() { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const autos = await prisma.auto.findMany({ const autos = await prisma.auto.findMany({
@@ -15,7 +15,7 @@ export async function GET() {
} }
export async function POST(req: NextRequest) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { patente, marca, modelo, anio, nroChasis } = await req.json(); 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"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); 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"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET() { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefes = await prisma.jefeMecanico.findMany({ 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 { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { aFormatoWhatsapp, esLocalArgentinoValido, normalizarTelefono } from "@/lib/telefono";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@@ -18,11 +19,29 @@ export async function POST(req: NextRequest) {
if (!email || !nombre || !apellido || !telefono) if (!email || !nombre || !apellido || !telefono)
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 }); 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() } }); const existe = await prisma.cliente.findUnique({ where: { email: email.toLowerCase() } });
if (existe) return NextResponse.json({ error: "El email ya está registrado" }, { status: 409 }); 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({ 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({ await transporter.sendMail({
+1 -1
View File
@@ -6,7 +6,7 @@ import { whereTrabajosDeJefe } from "@/lib/servicios";
// Trabajos que ofrece un jefe mecánico: los suyos + los globales del taller. // 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. // El flujo del cliente es jefe → trabajo, así que el jefe es obligatorio.
export async function GET(req: NextRequest) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId"); const jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId");
+1 -1
View File
@@ -5,7 +5,7 @@ import { obtenerSlotsDisponibles } from "@/lib/scheduling";
import { trabajoEsDeJefe } from "@/lib/servicios"; import { trabajoEsDeJefe } from "@/lib/servicios";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
+2 -2
View File
@@ -4,7 +4,7 @@ import { verificarSesion } from "@/lib/auth-admin";
import { crearTurno } from "@/lib/turnos"; import { crearTurno } from "@/lib/turnos";
export async function GET() { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const turnos = await prisma.turno.findMany({ const turnos = await prisma.turno.findMany({
@@ -17,7 +17,7 @@ export async function GET() {
} }
export async function POST(req: NextRequest) { 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 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json(); const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json();
+4 -4
View File
@@ -4,8 +4,8 @@ import { verificarSesion } from "@/lib/auth-admin";
// Edita un trabajo propio del jefe (nombre, duración, activo). Solo si le pertenece. // 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 }> }) { export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("jefe_session"); const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion || sesion.role !== "jefe") if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 }); return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
@@ -42,8 +42,8 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
// Elimina un trabajo propio. Si ya tiene turnos asociados, lo desactiva en vez de borrarlo // 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). // (para no romper la integridad referencial de los turnos históricos).
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("jefe_session"); const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion || sesion.role !== "jefe") if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 }); return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
+4 -4
View File
@@ -4,8 +4,8 @@ import { verificarSesion } from "@/lib/auth-admin";
// Lista los trabajos cargados por el jefe logueado (los suyos, no los globales). // Lista los trabajos cargados por el jefe logueado (los suyos, no los globales).
export async function GET() { export async function GET() {
const sesion = await verificarSesion("jefe_session"); const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion || sesion.role !== "jefe") if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 }); return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({ const servicios = await prisma.tipoServicio.findMany({
@@ -18,8 +18,8 @@ export async function GET() {
// Crea un trabajo propio del jefe (nombre + duración en horas). // Crea un trabajo propio del jefe (nombre + duración en horas).
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const sesion = await verificarSesion("jefe_session"); const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion || sesion.role !== "jefe") if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 }); return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json(); const { nombre, duracionHoras } = await req.json();
+2 -2
View File
@@ -3,8 +3,8 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("jefe_session"); const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion || sesion.role !== "jefe") if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 }); return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
+5
View File
@@ -68,10 +68,15 @@ export default function DashboardPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Mis autos</h1> <h1 className="text-2xl font-bold text-white">Mis autos</h1>
<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"> <button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
Salir Salir
</button> </button>
</div> </div>
</div>
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
+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 Button from "@/components/ui/Button";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import { esLocalArgentinoValido } from "@/lib/telefono";
export default function RegistroPage() { export default function RegistroPage() {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ email: "", nombre: "", apellido: "", telefono: "" }); const [form, setForm] = useState({ email: "", nombre: "", apellido: "", telefono: "" });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [telefonoError, setTelefonoError] = useState("");
function set(field: string) { function set(field: string) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [field]: e.target.value })); 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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); 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); setLoading(true);
setError(""); setError("");
const res = await fetch("/api/cliente/registro", { 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="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="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="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>} {error && <p className="text-sm text-red-500 text-center">{error}</p>}
<Button type="submit" loading={loading} className="w-full"> <Button type="submit" loading={loading} className="w-full">
Crear cuenta Crear cuenta
+2 -2
View File
@@ -14,8 +14,8 @@ const geistMono = localFont({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Taller MG", title: "Taller Beldor",
description: "Sistema de turnos - Taller MG", description: "Sistema de turnos - Taller Beldor",
}; };
export default function RootLayout({ export default function RootLayout({
+21 -4
View File
@@ -3,9 +3,16 @@ import { InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
error?: 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 ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{label && ( {label && (
@@ -13,13 +20,23 @@ export default function Input({ label, error, className = "", id, ...props }: In
{label} {label}
</label> </label>
)} )}
{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 <input
id={id} 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 ${ className={`flex-1 bg-transparent px-1 py-2 text-sm outline-none text-gray-900 placeholder-gray-500 ${className}`}
error ? "border-red-400" : "border-gray-400"
} ${className}`}
{...props} {...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>} {error && <p className="text-xs text-red-500">{error}</p>}
</div> </div>
); );
+8 -2
View File
@@ -28,14 +28,20 @@ export async function crearSesionJefe(jefeId: string): Promise<string> {
} }
export async function verificarSesion( export async function verificarSesion(
cookieName: string cookieName: string,
rolEsperado?: "admin" | "cliente" | "jefe"
): Promise<{ sub: string; role: string } | null> { ): Promise<{ sub: string; role: string } | null> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value; const token = cookieStore.get(cookieName)?.value;
if (!token) return null; if (!token) return null;
try { try {
const { payload } = await jwtVerify(token, SECRET); 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 { } catch {
return null; return null;
} }
+33
View File
@@ -6,3 +6,36 @@
export function normalizarTelefono(valor: string): string { export function normalizarTelefono(valor: string): string {
return (valor ?? "").replace(/\D/g, ""); 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;
}
+1 -1
View File
@@ -183,7 +183,7 @@ async function guardar(
return resp; return resp;
} }
const BIENVENIDA = "¡Hola! 👋 Soy el asistente del *Taller MG*. Te ayudo a sacar un turno."; const BIENVENIDA = "¡Hola! 👋 Soy el asistente del *Taller Beldor*. Te ayudo a sacar un turno.";
// ── Entrada principal ── // ── Entrada principal ──
export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Promise<RespuestaBot[]> { export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Promise<RespuestaBot[]> {
+9 -6
View File
@@ -3,12 +3,14 @@ import { jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET || "dev-secret"); 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; const token = req.cookies.get(cookieName)?.value;
if (!token) return false; if (!token) return false;
try { try {
await jwtVerify(token, SECRET); const { payload } = await jwtVerify(token, SECRET);
return true; return (payload as { role?: string }).role === rol;
} catch { } catch {
return false; return false;
} }
@@ -19,8 +21,9 @@ export async function middleware(req: NextRequest) {
if (pathname.startsWith("/cliente/dashboard") || if (pathname.startsWith("/cliente/dashboard") ||
pathname.startsWith("/cliente/historial") || pathname.startsWith("/cliente/historial") ||
pathname.startsWith("/cliente/perfil") ||
pathname.startsWith("/cliente/turnos")) { 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)); 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/servicios") ||
pathname.startsWith("/admin/jefes") || pathname.startsWith("/admin/jefes") ||
pathname.startsWith("/admin/clientes")) { 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 (!ok) return NextResponse.redirect(new URL("/admin", req.url));
} }
if (pathname.startsWith("/jefe/dashboard")) { 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)); if (!ok) return NextResponse.redirect(new URL("/jefe", req.url));
} }