3 Commits

Author SHA1 Message Date
jpbeldomenico 91f5fba49f 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-04 19:00:20 -03:00
Beldor c1b4ad5ad2 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-07-27 20:44:33 -03:00
Beldor 2095cb542a 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-07-27 19:01:54 -03:00
44 changed files with 154 additions and 1229 deletions
-60
View File
@@ -1,60 +0,0 @@
# Configuración de la revisión con IA (https://github.com/Nikita-Filonov/ai-review)
#
# Acá va sólo lo estático. Los tokens y el contexto del pull request llegan por
# variables de entorno desde .gitea/workflows/ai-review.yml, con la convención
# de doble guión bajo que usa la herramienta (VCS__PIPELINE__OWNER, etc.).
# Ojo: la sintaxis ${VARIABLE} dentro de este archivo NO se resuelve.
llm:
provider: OPENROUTER
meta:
# Modelo sin costo, 1M de contexto.
model: nvidia/nemotron-3-ultra-550b-a55b:free
max_tokens: 8000
temperature: 0.2
# Viaja como cabecera HTTP y se codifica en ASCII: sin acentos ni enies.
title: "Revision IA Taller"
http_client:
timeout: 180
api_url: https://openrouter.ai/api/v1
# --- Si este modelo deja de estar disponible ---
# El catalogo gratuito de OpenRouter rota. Si el job falla con 404 de modelo,
# listar los vigentes y reemplazar la clave model:
# curl -s https://openrouter.ai/api/v1/models | grep -o '"id":"[^"]*:free"'
# Alternativas verificadas el 03/08/2026:
# cohere/north-mini-code:free
# nvidia/nemotron-3-super-120b-a12b:free
core:
# El plan gratuito de OpenRouter admite 20 pedidos por minuto.
concurrency: 3
review:
mode: FULL_FILE_DIFF
ignore_pure_renames: true
max_context_comments: 15
max_inline_comments: 10
ignore_changes:
- "package-lock.json"
- "*.lock"
- "*.min.js"
- "*.min.css"
- "*.svg"
- "*.png"
- "*.jpg"
- "*.jpeg"
- "*.ico"
- "*.woff*"
- "public/**"
- "tsconfig.tsbuildinfo"
prompt:
context_prompt_files:
- ./.gitea/ai-review/context.md
summary_prompt_files:
- ./.gitea/ai-review/summary.md
-68
View File
@@ -1,68 +0,0 @@
# Revisión de código — Taller Automotriz
Escribí siempre en español rioplatense, directo y sin rodeos.
Recibís **todos los archivos cambiados del pull request juntos**, no uno por
vez. Aprovechalo: además de los errores dentro de cada archivo, buscá los que
sólo se ven mirando el conjunto.
## Contexto del proyecto
Sistema de turnos para un taller mecánico. Next.js (App Router) con TypeScript,
Prisma sobre PostgreSQL:
- `src/app/api` — endpoints.
- `src/app/{admin,jefe,cliente}` — las tres áreas, con permisos distintos.
- `src/lib` — la lógica que importa: `scheduling.ts` y `turnos.ts` (slots y
reservas), `whatsapp-bot.ts` (bot de reservas), `magic-link.ts` y
`auth-admin.ts` (autenticación), `prisma.ts` (cliente).
- `prisma/schema.prisma` — modelo de datos: `Cliente`, `Auto`, `Turno`,
`TipoServicio`, `JefeMecanico`, `HistorialServicio`, `Admin`, `MagicToken`,
`ConversacionWhatsapp`.
## Qué mirar con prioridad
1. **Turnos y disponibilidad**: es el corazón del sistema. Reservas que se
pisan, husos horarios, slots que se calculan distinto en dos lugares,
condiciones de carrera entre dos clientes reservando el mismo horario,
estados de `Turno` que cambian sin validar la transición.
2. **Autorización por área**: `admin`, `jefe` y `cliente` ven cosas distintas.
Un endpoint que no verifica el rol en el backend, o que confía en lo que
manda el cliente, es un hallazgo grave. Los magic links son credenciales:
token que no expira, que se reutiliza o que se registra en un log.
3. **Bugs reales**: promesas sin `await`, `null`/`undefined` sin contemplar,
errores tragados, casos borde sin cubrir.
4. **Prisma**: migraciones que no acompañan al cambio de `schema.prisma`,
consultas dentro de un bucle (N+1), operaciones que deberían ir en una
transacción y no lo están.
5. **Coherencia entre archivos**: un tipo que cambia en `src/lib` y deja
desactualizado a quien lo consume, lógica de negocio duplicada entre un
endpoint y el bot de WhatsApp en vez de vivir en `src/lib`.
## Cuidados propios de este proyecto
- Los datos de clientes (teléfono, email, patente) son personales: no deben
terminar en logs ni en respuestas de error.
- El bot de WhatsApp responde a mensajes de afuera: todo lo que llega por ahí
es entrada no confiable y hay que validarlo.
- Nunca claves ni tokens en el código, los documentos o los mensajes.
## Antes de afirmar
Si tu observación depende de cómo se comporta una función del lenguaje, de
Next.js o de Prisma, verificá que estás seguro. Si no lo estás, plantealo como
pregunta ("¿confirmaste que X hace Y?") en vez de afirmarlo. Un señalamiento
correcto apoyado en una premisa falsa hace perder tiempo y resta credibilidad al
resto de la revisión.
## Qué no comentar
- Formato, comillas, punto y coma o largo de línea: de eso se ocupa el linter.
- Preferencias de estilo sin impacto en el comportamiento.
- Renombres de variables porque te suenan mejor.
- Reescrituras completas de un archivo que ya funciona.
- Elogios o comentarios que sólo describen lo que hace el código.
Como ves muchos archivos de una sola vez, resistí la tentación de comentar en
todos para parecer completo. Si de veinte archivos sólo tres tienen algo que
decir, comentá esos tres. Un comentario ruidoso cuesta más que uno faltante.
-23
View File
@@ -1,23 +0,0 @@
# Resumen del pull request — J2 ProDE
Escribí en español rioplatense. Sé breve: quien lee quiere decidir si aprobar o
no, no leer una redacción.
Estructura:
1. **Qué cambia** — dos o tres oraciones sobre la intención del cambio, no un
listado de archivos tocados.
2. **Riesgos** — sólo si los hay de verdad. Priorizá: violaciones de las
invariantes del proyecto, cambios de contrato en `packages/contracts`,
migraciones de PostgreSQL, autorización, y todo lo que pueda romper un
despliegue en curso.
3. **Antes de aprobar** — verificaciones concretas que convenga correr a mano si
el gate (`pnpm check`) no las cubre.
Reglas:
- Si no hay riesgos, decilo en una línea y terminá.
- No repitas el diff ni parafrasees cada archivo.
- No felicites al autor.
- Ante la duda de si algo es un problema, decilo como duda y explicá qué
verificar, sin afirmar que está roto.
-244
View File
@@ -1,244 +0,0 @@
name: Revisión con IA
on:
# Incluimos "synchronize" desde que la revision pasó al modo por contexto:
# antes costaba un pedido por archivo (unos veinte por revision) y revisar
# cada push era inviable contra el limite diario; ahora cuesta dos, asi que
# conviene que el pull request se revise de nuevo al corregirlo. Ademas el
# estado del check queda al dia: sin esto un pull request arreglado seguia
# figurando en rojo por la corrida vieja.
pull_request:
types: [opened, reopened, synchronize]
workflow_dispatch:
inputs:
pull-request-number:
description: "Número de PR a revisar"
required: true
type: string
review-command:
description: "Modo de revisión"
required: false
default: auto
type: choice
options:
# Lo mismo que hace el disparo automático: contexto + resumen,
# dos pedidos al modelo en total.
- auto
# Un pedido por archivo cambiado. Más minucioso y mucho más caro
# en cuota: con 20 archivos son 20 pedidos.
- run-inline
- run-context
- run-summary
- clear-inline
- clear-summary
jobs:
ai-review:
runs-on: ai-review
timeout-minutes: 20
steps:
- name: Descargar el código
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Preparar Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Instalar la herramienta de revisión
run: pip install xai-review==0.71.0
- name: Leer los secretos de Infisical
env:
INFISICAL_URL: ${{ vars.INFISICAL_URL }}
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}
INFISICAL_PROJECT_ID: ${{ vars.INFISICAL_PROJECT_ID }}
INFISICAL_ENV: ${{ vars.INFISICAL_ENV }}
INFISICAL_PATH: ${{ vars.INFISICAL_PATH }}
run: |
set -eu
# Infisical vive en la misma red docker que este job, asi que le
# hablamos por DNS interno sin salir a Internet.
: "${INFISICAL_URL:=http://infisical:8080}"
: "${INFISICAL_PROJECT_ID:=4db7af6a-1f7a-445d-a5f3-fddb9e64b614}"
: "${INFISICAL_ENV:=prod}"
: "${INFISICAL_PATH:=/gitea-ai-review}"
export INFISICAL_URL INFISICAL_PROJECT_ID INFISICAL_ENV INFISICAL_PATH
python3 - <<'PY'
import json, os, sys, time, urllib.error, urllib.parse, urllib.request
base = os.environ["INFISICAL_URL"].rstrip("/")
INTENTOS = 3
ESPERA = 5
def diagnosticar(error):
"""Devuelve (mensaje, si_conviene_reintentar)."""
if isinstance(error, urllib.error.HTTPError):
motivos = {
400: "el pedido fue rechazado; revisar el identificador del proyecto",
401: "credenciales rechazadas; revisar INFISICAL_CLIENT_ID y INFISICAL_CLIENT_SECRET",
403: "la identidad de maquina no tiene permiso sobre el proyecto",
404: "no existe el proyecto, el entorno o la ruta indicada",
429: "demasiados pedidos a Infisical",
}
motivo = motivos.get(error.code, "respuesta inesperada del servicio")
# Reintentar solo lo transitorio: un 401 no se arregla esperando.
return f"HTTP {error.code} — {motivo}", error.code >= 500 or error.code == 429
if isinstance(error, urllib.error.URLError):
return f"no se pudo contactar a {base} ({error.reason})", True
return f"error inesperado: {error}", False
def call(path, data=None, token=None):
ultimo = None
for intento in range(1, INTENTOS + 1):
req = urllib.request.Request(base + path)
if token:
req.add_header("Authorization", "Bearer " + token)
if data is not None:
req.add_header("Content-Type", "application/json")
req.data = json.dumps(data).encode()
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except Exception as exc: # noqa: BLE001 - se clasifica abajo
mensaje, reintentable = diagnosticar(exc)
ultimo = mensaje
if not reintentable or intento == INTENTOS:
break
print(f"Infisical: {mensaje}. Reintento {intento}/{INTENTOS - 1} en {ESPERA}s.",
file=sys.stderr)
time.sleep(ESPERA)
print(f"No se pudieron leer los secretos de Infisical: {ultimo}", file=sys.stderr)
raise SystemExit(1)
auth = call("/api/v1/auth/universal-auth/login", {
"clientId": os.environ["INFISICAL_CLIENT_ID"],
"clientSecret": os.environ["INFISICAL_CLIENT_SECRET"],
})
query = urllib.parse.urlencode({
"workspaceId": os.environ["INFISICAL_PROJECT_ID"],
"environment": os.environ["INFISICAL_ENV"],
"secretPath": os.environ["INFISICAL_PATH"],
})
secrets = call("/api/v3/secrets/raw?" + query, token=auth["accessToken"])
wanted = {"OPENROUTER_API_KEY": "LLM__HTTP_CLIENT__API_TOKEN",
"AI_REVIEW_GITEA_TOKEN": "VCS__HTTP_CLIENT__API_TOKEN"}
found = {s["secretKey"]: s["secretValue"] for s in secrets["secrets"]}
faltan = [k for k in wanted if not found.get(k)]
if faltan:
print("Faltan secretos en Infisical: " + ", ".join(faltan), file=sys.stderr)
raise SystemExit(1)
with open(os.environ["GITHUB_ENV"], "a") as env_file:
for clave, variable in wanted.items():
valor = found[clave]
# Que el runner lo tache si alguna vez se imprime.
print(f"::add-mask::{valor}")
env_file.write(f"{variable}={valor}\n")
print(f"Secretos leidos de Infisical: {', '.join(wanted)}")
PY
- name: Revisar el pull request
env:
VCS__PROVIDER: GITEA
VCS__PIPELINE__OWNER: ${{ gitea.repository_owner }}
VCS__PIPELINE__REPO: ${{ gitea.event.repository.name }}
# El job corre en la red "proxy" del runner, la misma del contenedor
# gitea: hablamos con la API por DNS interno en vez de salir a
# Internet para volver por el proxy. Se puede sobrescribir con la
# variable de repositorio GITEA_API_URL.
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_NUMBER_INPUT: ${{ inputs.pull-request-number }}
REVIEW_COMMAND: ${{ inputs.review-command }}
run: |
set -eu
: "${GITEA_API_URL:=http://gitea:3000/api/v1}"
: "${PR_NUMBER:=${PR_NUMBER_INPUT}}"
: "${REVIEW_COMMAND:=auto}"
if [ -z "${PR_NUMBER}" ]; then
echo "No hay número de pull request para revisar"
exit 1
fi
export VCS__HTTP_CLIENT__API_URL="${GITEA_API_URL}"
export VCS__PIPELINE__PULL_NUMBER="${PR_NUMBER}"
# xai-review termina con codigo 0 aunque falle la publicacion de los
# 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() {
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
# imprime los tokens resueltos: no correrlo dentro del pipeline.
if [ "${REVIEW_COMMAND}" = "auto" ]; then
# La revisión por contexto manda todos los archivos en un único
# pedido y aun así deja los comentarios pegados a cada línea, así
# que el pull request completo cuesta dos pedidos al modelo.
revisar run-context
revisar run-summary
else
revisar "${REVIEW_COMMAND}"
fi
+2 -26
View File
@@ -10,33 +10,9 @@ jobs:
steps: steps:
- name: Pull y rebuild - name: Pull y rebuild
run: | run: |
set -eu
git config --global --add safe.directory /home/proyecto/taller_automotriz git config --global --add safe.directory /home/proyecto/taller_automotriz
cd /home/proyecto/taller_automotriz cd /home/proyecto/taller_automotriz
git fetch origin git fetch origin
git reset --hard origin/main git reset --hard origin/main
docker compose down
# Construir ANTES de tocar lo que esta corriendo. Antes este paso docker compose up -d --build
# hacia "docker compose down" y recien despues "up --build": si la
# construccion fallaba (por ejemplo, npm sin poder bajar las
# dependencias), el taller quedaba caido hasta que alguien lo
# notara. Pasó el 03/08/2026.
docker compose build
# Con la imagen nueva ya lista, "up -d" recrea los contenedores que
# cambiaron. No hace falta bajar nada de antemano.
docker compose up -d --no-build --remove-orphans
- name: Verificar que quedo arriba
run: |
set -eu
cd /home/proyecto/taller_automotriz
docker compose ps
# Si el contenedor no esta corriendo, el despliegue fallo aunque los
# comandos anteriores hayan salido bien.
if [ -z "$(docker compose ps --quiet --status running)" ]; then
echo "El despliegue termino sin contenedores corriendo"
docker compose logs --tail 50
exit 1
fi
-7
View File
@@ -33,13 +33,6 @@ 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"
+1 -3
View File
@@ -35,9 +35,7 @@ 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 (`/home/proyecto/taller_automotriz/.env`, 5. Cargar las 4 variables en el `.env` de producción y redeployar.
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`
-10
View File
@@ -136,13 +136,3 @@ model ConversacionWhatsapp {
cliente Cliente? @relation(fields: [clienteId], references: [id]) 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
@@ -1,54 +0,0 @@
/**
* 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);
});
-35
View File
@@ -24,10 +24,6 @@ function match(row: Row, where: Row): boolean {
if (!(v as Row[]).every((w) => match(row, w))) return false; if (!(v as Row[]).every((w) => match(row, w))) return false;
continue; continue;
} }
if (k === "OR") {
if (!(v as Row[]).some((w) => match(row, w))) return false;
continue;
}
const rv = row[k]; const rv = row[k];
if (v !== null && typeof v === "object" && !(v instanceof Date)) { if (v !== null && typeof v === "object" && !(v instanceof Date)) {
if ("in" in v && !(v.in as any[]).includes(rv)) return false; if ("in" in v && !(v.in as any[]).includes(rv)) return false;
@@ -138,14 +134,6 @@ const fakePrisma: any = {
return out; 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; (globalThis as any).prisma = fakePrisma;
@@ -155,8 +143,6 @@ jefes.push({ id: "j1", nombre: "Carlos", email: "j1@t.com", activo: true, slotsP
jefes.push({ id: "j2", nombre: "Diego", email: "j2@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: "s1", nombre: "Cambio de aceite", duracionHoras: 1, activo: true, jefeMecanicoId: "j1" });
servicios.push({ id: "s2", nombre: "Alineacion", duracionHoras: 2, 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({ clientes.push({
id: "c1", id: "c1",
nombre: "Juan", nombre: "Juan",
@@ -201,7 +187,6 @@ async function main() {
r = await env(A, "j1"); r = await env(A, "j1");
ok(/Cambio de aceite/i.test(flat(r)), "muestra trabajos del jefe elegido"); 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"); ok(paso(A) === "ELEGIR_TRABAJO", "paso=ELEGIR_TRABAJO");
r = await env(A, "a"); r = await env(A, "a");
@@ -240,26 +225,6 @@ async function main() {
ok(/cancel/i.test(flat(r)), "cancelar resetea"); ok(/cancel/i.test(flat(r)), "cancelar resetea");
ok(paso(B) === "INICIO", "paso=INICIO tras cancelar"); 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`); console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`);
process.exit(fallos === 0 ? 0 : 1); process.exit(fallos === 0 ? 0 : 1);
} }
+1 -31
View File
@@ -32,8 +32,6 @@ function ok(cond: boolean, msg: string) {
// ── Stub de fetch: guarda lo que se le manda a Meta en vez de salir a internet ── // ── 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 }; type Llamada = { url: string; auth: string; body: any };
let llamadas: Llamada[] = []; 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) => { globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>; const headers = (init?.headers ?? {}) as Record<string, string>;
llamadas.push({ llamadas.push({
@@ -41,8 +39,7 @@ globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
auth: headers["Authorization"] ?? "", auth: headers["Authorization"] ?? "",
body: JSON.parse(String(init?.body ?? "{}")), body: JSON.parse(String(init?.body ?? "{}")),
}); });
const status = respuestas.shift() ?? 200; return { ok: true, status: 200, text: async () => "" } as Response;
return { ok: status < 400, status, text: async () => "detalle" } as Response;
}) as typeof fetch; }) as typeof fetch;
async function main() { async function main() {
@@ -100,33 +97,6 @@ async function main() {
ok(llamadas.length === 2, "manda las respuestas múltiples en orden"); 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"); 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 ──"); console.log("\n── Firma del webhook ──");
const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" }); const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" });
const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex"); const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex");
+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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "admin"); const sesion = await verificarSesion("admin_session");
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", "cliente"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const 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", "cliente"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { 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", "cliente"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { 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", "cliente"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefes = await prisma.jefeMecanico.findMany({ const jefes = await prisma.jefeMecanico.findMany({
-97
View File
@@ -1,97 +0,0 @@
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 ?? ""),
});
}
+1 -20
View File
@@ -1,6 +1,5 @@
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({
@@ -19,29 +18,11 @@ 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: { data: { email: email.toLowerCase().trim(), nombre, apellido, telefono },
email: email.toLowerCase().trim(),
nombre,
apellido,
telefono: normalizarTelefono(telefono),
telefonoNorm,
},
}); });
await transporter.sendMail({ await transporter.sendMail({
+4 -19
View File
@@ -1,28 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
import { whereTrabajosDeJefe } from "@/lib/servicios";
// Trabajos que ofrece un jefe mecánico: los suyos + los globales del taller. export async function GET() {
// El flujo del cliente es jefe → trabajo, así que el jefe es obligatorio. const sesion = await verificarSesion("cliente_session");
export async function GET(req: NextRequest) {
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");
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({ const servicios = await prisma.tipoServicio.findMany({
where: whereTrabajosDeJefe(jefeMecanicoId), where: { activo: true },
orderBy: { nombre: "asc" }, orderBy: { nombre: "asc" },
}); });
+1 -4
View File
@@ -2,10 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
import { obtenerSlotsDisponibles } from "@/lib/scheduling"; import { obtenerSlotsDisponibles } from "@/lib/scheduling";
import { trabajoEsDeJefe } from "@/lib/servicios";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session", "cliente"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
@@ -19,8 +18,6 @@ export async function GET(req: NextRequest) {
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } }); const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo) if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 }); return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
return NextResponse.json({ error: "Ese trabajo no lo hace este mecánico" }, { status: 400 });
const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500); const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500);
return NextResponse.json(slots); return NextResponse.json(slots);
+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", "cliente"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const 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", "cliente"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json(); const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json();
+5 -5
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", "jefe"); const sesion = await verificarSesion("jefe_session");
if (!sesion) if (!sesion || sesion.role !== "jefe")
return NextResponse.json({ error: "No autorizado" }, { status: 401 }); return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
@@ -20,7 +20,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
const dur = Number(data.duracionHoras); const dur = Number(data.duracionHoras);
if (!Number.isFinite(dur) || dur <= 0 || dur > 24) if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
return NextResponse.json( return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" }, { error: "La duración debe ser un número de horas entre 0 y 24" },
{ status: 400 } { status: 400 }
); );
} }
@@ -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", "jefe"); const sesion = await verificarSesion("jefe_session");
if (!sesion) if (!sesion || sesion.role !== "jefe")
return NextResponse.json({ error: "No autorizado" }, { status: 401 }); return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
+5 -5
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", "jefe"); const sesion = await verificarSesion("jefe_session");
if (!sesion) if (!sesion || sesion.role !== "jefe")
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", "jefe"); const sesion = await verificarSesion("jefe_session");
if (!sesion) if (!sesion || sesion.role !== "jefe")
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();
@@ -28,7 +28,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 }); return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 });
if (!Number.isFinite(dur) || dur <= 0 || dur > 24) if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
return NextResponse.json( return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" }, { error: "La duración debe ser un número de horas entre 0 y 24" },
{ status: 400 } { status: 400 }
); );
+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", "jefe"); const sesion = await verificarSesion("jefe_session");
if (!sesion) if (!sesion || sesion.role !== "jefe")
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);
+14 -31
View File
@@ -1,6 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { procesarMensaje } from "@/lib/whatsapp-bot"; import { procesarMensaje } from "@/lib/whatsapp-bot";
import { import {
enviarRespuestas, enviarRespuestas,
@@ -19,34 +17,21 @@ export const dynamic = "force-dynamic";
* POST → mensajes entrantes: se los pasa a la máquina de conversación y se contesta. * 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 // Meta reintenta el mismo mensaje si tardamos o si algo falla. Guardamos los ids ya
// crea el turno dos veces. Se resuelve insertando el id en la base: el índice único // procesados en memoria (un solo container) para no duplicar turnos ni respuestas.
// hace la operación atómica, así que sirve incluso con varias instancias corriendo. const procesados = new Set<string>();
const DIAS_RETENCION = 7; const MAX_PROCESADOS = 500;
let ultimaLimpieza = 0;
async function yaProcesado(id: string): Promise<boolean> { function yaProcesado(id: string): boolean {
try { if (procesados.has(id)) return true;
await prisma.mensajeWhatsappProcesado.create({ data: { id } }); procesados.add(id);
// Set mantiene orden de inserción: soltamos los más viejos.
while (procesados.size > MAX_PROCESADOS) {
const viejo: string | undefined = procesados.values().next().value;
if (viejo === undefined) break;
procesados.delete(viejo);
}
return false; 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) { export async function GET(req: NextRequest) {
@@ -79,12 +64,10 @@ export async function POST(req: NextRequest) {
} }
try { try {
await limpiarProcesadosViejos();
for (const mensaje of mensajesDelPayload(body)) { for (const mensaje of mensajesDelPayload(body)) {
const telefono = mensaje.from; const telefono = mensaje.from;
const messageId = mensaje.id; const messageId = mensaje.id;
if (!telefono || !messageId || (await yaProcesado(messageId))) continue; if (!telefono || !messageId || yaProcesado(messageId)) continue;
void marcarLeido(messageId).catch(() => {}); void marcarLeido(messageId).catch(() => {});
-5
View File
@@ -68,15 +68,10 @@ 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
@@ -1,166 +0,0 @@
"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>
);
}
+1 -38
View File
@@ -5,42 +5,19 @@ 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", {
@@ -70,21 +47,7 @@ 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 <Input id="telefono" type="tel" label="Teléfono" placeholder="11 1234-5678" value={form.telefono} onChange={set("telefono")} required />
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
+46 -65
View File
@@ -20,9 +20,7 @@ interface Turno {
const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]; const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"];
// El cliente elige primero el mecánico y después el trabajo, porque cada jefe type Step = "servicio" | "jefe" | "calendario" | "confirmado";
// carga sus propios trabajos (además de los globales del taller).
type Step = "jefe" | "servicio" | "calendario" | "confirmado";
function formatDuracion(h: number) { function formatDuracion(h: number) {
if (h < 1) return `${Math.round(h * 60)} min`; if (h < 1) return `${Math.round(h * 60)} min`;
@@ -68,13 +66,12 @@ function TurnosContent() {
const params = useSearchParams(); const params = useSearchParams();
const autoId = params.get("autoId"); const autoId = params.get("autoId");
const [step, setStep] = useState<Step>("jefe"); const [step, setStep] = useState<Step>("servicio");
const [servicios, setServicios] = useState<TipoServicio[]>([]); const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null); const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [loadingServicios, setLoadingServicios] = useState(false);
const [jefes, setJefes] = useState<Jefe[]>([]); const [jefes, setJefes] = useState<Jefe[]>([]);
const [jefeSel, setJefeSel] = useState<Jefe | null>(null); const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
const [loadingJefes, setLoadingJefes] = useState(true); const [loadingJefes, setLoadingJefes] = useState(false);
const [slots, setSlots] = useState<Slot[]>([]); const [slots, setSlots] = useState<Slot[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false); const [loadingSlots, setLoadingSlots] = useState(false);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]); const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
@@ -93,12 +90,7 @@ function TurnosContent() {
useEffect(() => { useEffect(() => {
if (!autoId) { router.push("/cliente/dashboard"); return; } if (!autoId) { router.push("/cliente/dashboard"); return; }
fetch("/api/cliente/jefes") fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios);
.then(r => r.json())
.then((data) => {
setJefes(Array.isArray(data) ? data : []);
setLoadingJefes(false);
});
fetch("/api/cliente/turnos") fetch("/api/cliente/turnos")
.then(r => r.json()) .then(r => r.json())
.then((data) => { .then((data) => {
@@ -118,30 +110,29 @@ function TurnosContent() {
return m; return m;
}, [slots]); }, [slots]);
async function seleccionarJefe(j: Jefe) { async function seleccionarServicio(s: TipoServicio) {
setJefeSel(j); setServicioSel(s);
setServicioSel(null); setJefeSel(null);
setServicios([]);
setSlots([]); setSlots([]);
setDiaSeleccionado(null); setDiaSeleccionado(null);
setSlotSel(null); setSlotSel(null);
setLoadingServicios(true); setLoadingJefes(true);
setStep("servicio"); setStep("jefe");
const res = await fetch(`/api/cliente/servicios?jefeMecanicoId=${j.id}`); const res = await fetch("/api/cliente/jefes");
const data = await res.json(); const data = await res.json();
setServicios(Array.isArray(data) ? data : []); setJefes(Array.isArray(data) ? data : []);
setLoadingServicios(false); setLoadingJefes(false);
} }
async function seleccionarServicio(s: TipoServicio) { async function seleccionarJefe(j: Jefe) {
if (!jefeSel) return; if (!servicioSel) return;
setServicioSel(s); setJefeSel(j);
setLoadingSlots(true); setLoadingSlots(true);
setSlots([]); setSlots([]);
setDiaSeleccionado(null); setDiaSeleccionado(null);
setSlotSel(null); setSlotSel(null);
setStep("calendario"); setStep("calendario");
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}&jefeMecanicoId=${jefeSel.id}`); const res = await fetch(`/api/cliente/slots?tipoServicioId=${servicioSel.id}&jefeMecanicoId=${j.id}`);
const data = await res.json(); const data = await res.json();
setSlots(Array.isArray(data) ? data : []); setSlots(Array.isArray(data) ? data : []);
setLoadingSlots(false); setLoadingSlots(false);
@@ -222,14 +213,14 @@ function TurnosContent() {
<div className="text-5xl">✅</div> <div className="text-5xl">✅</div>
<h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1> <h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1>
<Card className="text-left w-full max-w-sm"> <Card className="text-left w-full max-w-sm">
<p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
{turnoCreado.jefeMecanico && ( {turnoCreado.jefeMecanico && (
<> <>
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p> <p className="text-sm text-gray-500 mb-1">Jefe mecánico</p>
<p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p> <p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p>
</> </>
)} )}
<p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
<p className="text-sm text-gray-500 mb-1">Fecha</p> <p className="text-sm text-gray-500 mb-1">Fecha</p>
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", { <p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long" timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
@@ -245,46 +236,33 @@ function TurnosContent() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{step === "calendario" ? ( {step === "calendario" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : step === "servicio" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button> <Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : step === "jefe" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : ( ) : (
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link> <Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
)} )}
<h1 className="text-xl sm:text-2xl font-bold text-white"> <h1 className="text-xl sm:text-2xl font-bold text-white">
{step === "servicio" && "Elegí el servicio"}
{step === "jefe" && "Elegí el jefe mecánico"} {step === "jefe" && "Elegí el jefe mecánico"}
{step === "servicio" && `Trabajos de ${jefeSel?.nombre}`} {step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`}
{step === "calendario" && `${jefeSel?.nombre}${servicioSel ? ` · ${servicioSel.nombre}` : ""}`}
</h1> </h1>
</div> </div>
{/* STEP: jefe */} {/* STEP: servicio */}
{step === "jefe" && ( {step === "servicio" && (
<> <>
<div className="flex flex-col gap-3"> <div className="grid gap-3">
{loadingJefes ? ( {servicios.map(s => (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p> <button key={s.id} onClick={() => seleccionarServicio(s)}
) : jefes.length === 0 ? ( 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">
<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> <div>
<p className="font-medium text-gray-800">{j.nombre}</p> <p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver sus trabajos</p> <p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
</div> </div>
<span className="text-gray-400 text-lg">→</span> <span className="text-gray-400 text-lg">→</span>
</button> </button>
)) ))}
)}
</div> </div>
{!loadingTurnos && misTurnos.length > 0 && ( {!loadingTurnos && misTurnos.length > 0 && (
@@ -310,24 +288,27 @@ function TurnosContent() {
</> </>
)} )}
{/* STEP: servicio */} {/* STEP: jefe */}
{step === "servicio" && ( {step === "jefe" && (
<div className="grid gap-3"> <div className="flex flex-col gap-3">
{loadingServicios ? ( {loadingJefes ? (
<p className="text-center text-sm text-gray-200">Cargando trabajos...</p> <p className="text-center text-sm text-gray-200">Cargando jefes...</p>
) : servicios.length === 0 ? ( ) : jefes.length === 0 ? (
<Card> <Card>
<p className="text-sm text-gray-600 text-center"> <p className="text-sm text-gray-600 text-center">
{jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico. No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
</p> </p>
</Card> </Card>
) : ( ) : (
servicios.map(s => ( jefes.map((j) => (
<button key={s.id} onClick={() => seleccionarServicio(s)} <button
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"> 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> <div>
<p className="font-medium text-gray-800">{s.nombre}</p> <p className="font-medium text-gray-800">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p> <p className="text-sm text-gray-500 mt-0.5">Ver disponibilidad</p>
</div> </div>
<span className="text-gray-400 text-lg">→</span> <span className="text-gray-400 text-lg">→</span>
</button> </button>
+4 -21
View File
@@ -3,16 +3,9 @@ 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, prefijo, ayuda, className = "", id, ...props }: InputProps) { export default function Input({ label, error, 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 && (
@@ -20,23 +13,13 @@ export default function Input({ label, error, prefijo, ayuda, className = "", id
{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={`flex-1 bg-transparent px-1 py-2 text-sm outline-none text-gray-900 placeholder-gray-500 ${className}`} 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} {...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>
); );
+2 -8
View File
@@ -28,20 +28,14 @@ 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);
const sesion = payload as { sub: string; role: string }; return 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;
} }
+7 -17
View File
@@ -1,13 +1,5 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
/**
* Cliente de Prisma o el cliente de una transacción en curso. Permite que la
* verificación de disponibilidad corra dentro de la misma transacción que crea
* el turno (ver `crearTurno`), que es lo que evita reservas superpuestas.
*/
type ClienteDb = Prisma.TransactionClient;
const INICIO_H = 8; const INICIO_H = 8;
const INICIO_M = 30; const INICIO_M = 30;
const FIN_H = 17; const FIN_H = 17;
@@ -61,10 +53,9 @@ function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
async function turnosSolapados( async function turnosSolapados(
inicio: Date, inicio: Date,
fin: Date, fin: Date,
jefeMecanicoId: string, jefeMecanicoId: string
db: ClienteDb = prisma
): Promise<number> { ): Promise<number> {
const count = await db.turno.count({ const count = await prisma.turno.count({
where: { where: {
jefeMecanicoId, jefeMecanicoId,
estado: { in: ["PENDIENTE", "CONFIRMADO"] }, estado: { in: ["PENDIENTE", "CONFIRMADO"] },
@@ -74,8 +65,8 @@ async function turnosSolapados(
return count; return count;
} }
async function obtenerJefeActivo(jefeMecanicoId: string, db: ClienteDb = prisma) { async function obtenerJefeActivo(jefeMecanicoId: string) {
return db.jefeMecanico.findUnique({ return prisma.jefeMecanico.findUnique({
where: { id: jefeMecanicoId }, where: { id: jefeMecanicoId },
select: { slotsParalelos: true, activo: true }, select: { slotsParalelos: true, activo: true },
}); });
@@ -142,12 +133,11 @@ export async function obtenerSlotsDisponibles(
export async function slotEstaDisponible( export async function slotEstaDisponible(
inicio: Date, inicio: Date,
fin: Date, fin: Date,
jefeMecanicoId: string, jefeMecanicoId: string
db: ClienteDb = prisma
): Promise<boolean> { ): Promise<boolean> {
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false; if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
const jefe = await obtenerJefeActivo(jefeMecanicoId, db); const jefe = await obtenerJefeActivo(jefeMecanicoId);
if (!jefe || !jefe.activo) return false; if (!jefe || !jefe.activo) return false;
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId, db); const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId);
return ocupados < jefe.slotsParalelos; return ocupados < jefe.slotsParalelos;
} }
-24
View File
@@ -1,24 +0,0 @@
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;
}
-33
View File
@@ -6,36 +6,3 @@
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;
}
+3 -17
View File
@@ -1,6 +1,5 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { slotEstaDisponible } from "./scheduling"; import { slotEstaDisponible } from "./scheduling";
import { trabajoEsDeJefe } from "./servicios";
export interface CrearTurnoInput { export interface CrearTurnoInput {
clienteId: string; clienteId: string;
@@ -31,22 +30,12 @@ export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResu
if (!jefe || !jefe.activo) if (!jefe || !jefe.activo)
return { ok: false, error: "Jefe mecánico no disponible", status: 404 }; 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); 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 const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId);
// de Postgres: si no, dos clientes pueden pasar la verificación al mismo tiempo if (!disponible) return { ok: false, error: "El horario ya no está disponible", status: 409 };
// 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); const turno = await prisma.turno.create({
if (!disponible) return null;
return tx.turno.create({
data: { data: {
clienteId, clienteId,
jefeMecanicoId, jefeMecanicoId,
@@ -62,9 +51,6 @@ export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResu
jefeMecanico: { select: { id: true, nombre: 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 }; return { ok: true, turno };
} }
+2 -3
View File
@@ -3,7 +3,6 @@ import { prisma } from "./prisma";
import { obtenerSlotsDisponibles } from "./scheduling"; import { obtenerSlotsDisponibles } from "./scheduling";
import { crearTurno } from "./turnos"; import { crearTurno } from "./turnos";
import { normalizarTelefono } from "./telefono"; 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) ── // ── 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 BotonBot = { id: string; titulo: string };
@@ -97,7 +96,7 @@ async function pasoElegirMecanico(): Promise<{ resp: RespuestaBot[]; datos: Dato
async function pasoElegirTrabajo(datos: DatosConv): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> { async function pasoElegirTrabajo(datos: DatosConv): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const servicios = await prisma.tipoServicio.findMany({ const servicios = await prisma.tipoServicio.findMany({
where: whereTrabajosDeJefe(datos.jefeMecanicoId!), where: { jefeMecanicoId: datos.jefeMecanicoId, activo: true },
orderBy: { nombre: "asc" }, orderBy: { nombre: "asc" },
}); });
@@ -304,7 +303,7 @@ export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Pr
if (!op) if (!op)
return guardar(conv.id, "ELEGIR_TRABAJO", datos, [{ tipo: "texto", texto: "No entendí. Respondé con la letra del trabajo." }]); 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 } }); const servicio = await prisma.tipoServicio.findUnique({ where: { id: op.id } });
if (!servicio || !servicio.activo || !trabajoEsDeJefe(servicio, datos.jefeMecanicoId!)) { if (!servicio || !servicio.activo) {
const m = await pasoElegirTrabajo(datos); const m = await pasoElegirTrabajo(datos);
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "Ese trabajo ya no está disponible." }, ...m.resp]); return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "Ese trabajo ya no está disponible." }, ...m.resp]);
} }
+5 -37
View File
@@ -42,16 +42,6 @@ export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): bo
return crypto.timingSafeEqual(a, b); 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> { async function postMensaje(payload: Record<string, unknown>): Promise<void> {
const cred = credenciales(); const cred = credenciales();
if (!cred) { if (!cred) {
@@ -59,40 +49,18 @@ async function postMensaje(payload: Record<string, unknown>): Promise<void> {
return; return;
} }
const url = `https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`; const res = await fetch(`https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`, {
const init: RequestInit = {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${cred.token}`, Authorization: `Bearer ${cred.token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ messaging_product: "whatsapp", ...payload }), body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),
}; });
for (let intento = 1; intento <= INTENTOS; intento++) { if (!res.ok) {
let status: number; const detalle = await res.text().catch(() => "");
let detalle = ""; console.error(`[whatsapp] error ${res.status} al enviar mensaje: ${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);
} }
} }
+6 -9
View File
@@ -3,14 +3,12 @@ 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");
// Las tres sesiones se firman con el mismo secreto, así que además de la firma hay async function verifyCookie(req: NextRequest, cookieName: string) {
// 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 {
const { payload } = await jwtVerify(token, SECRET); await jwtVerify(token, SECRET);
return (payload as { role?: string }).role === rol; return true;
} catch { } catch {
return false; return false;
} }
@@ -21,9 +19,8 @@ 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", "cliente"); const ok = await verifyCookie(req, "cliente_session");
if (!ok) return NextResponse.redirect(new URL("/cliente", req.url)); if (!ok) return NextResponse.redirect(new URL("/cliente", req.url));
} }
@@ -33,12 +30,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", "admin"); const ok = await verifyCookie(req, "admin_session");
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", "jefe"); const ok = await verifyCookie(req, "jefe_session");
if (!ok) return NextResponse.redirect(new URL("/jefe", req.url)); if (!ok) return NextResponse.redirect(new URL("/jefe", req.url));
} }