11 Commits

Author SHA1 Message Date
Beldor 44aeb58e77 feat(cliente): invertir el flujo web a jefe -> trabajo
Revisión con IA / ai-review (pull_request) Failing after 2m12s
Los trabajos ahora son por jefe mecanico, asi que el cliente elige primero
el mecanico y despues el trabajo, igual que en el bot de WhatsApp. Antes
listaba todos los servicios del taller y recien despues pedia el jefe.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:55:47 -03:00
jpbeldomenico 15d6e8a2ee feat(whatsapp): webhook + envio por Graph API (F0)
Ultima fase del bot de reservas: conecta la maquina de conversacion ya
existente (whatsapp-bot.ts) con la WhatsApp Business Cloud API.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 09:55:47 -03:00
Beldor c53e5bb5aa feat(whatsapp): schema del bot de reservas + panel de trabajos por jefe (F1+F3)
FASE 1 — schema:
- Cliente.email opcional; nuevo telefonoNorm (E.164, único) para matchear por WhatsApp
- TipoServicio.jefeMecanicoId opcional (trabajos per-jefe; globales legacy quedan null)
- Turno.autoId/kilometros opcionales (reservas por WhatsApp sin auto/km)
- Nuevo modelo ConversacionWhatsapp (estado del bot por teléfono)
- admin/turnos/[id]: no permite COMPLETADO sin auto+km; Sheets tolera email nulo

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 09:55:47 -03:00
jpbeldomenico dd7f73453e Merge pull request 'ci: reintentar la revision con IA cuando falla el proveedor' (#3) from fix/ai-review-reintentos into main
Deploy / deploy (push) Successful in 3m22s
2026-08-03 22:50:20 -03:00
jpbeldomenico a312815140 fix(ci): no dar por fallida una revision por el texto de su comentario
Revisión con IA / ai-review (pull_request) Successful in 1m59s
El guard buscaba "Error: " en el log, pero el log tambien trae lo que escribe
el modelo. En inventario#3 la revision salio bien y comento justamente que ese
patron era demasiado amplio: el comentario quedo impreso, matcheo el patron y
el job quedo en rojo. Lo encontro la propia revision con IA.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:10:32 -03:00
jpbeldomenico d74c97e532 Merge pull request 'ci: construir antes de bajar el servicio' (#2) from fix/deploy-robusto into main
Deploy / deploy (push) Successful in 4m46s
2026-08-03 20:04:35 -03:00
jpbeldomenico f1ef9da857 fix(ci): construir antes de bajar el servicio
Revisión con IA / ai-review (pull_request) Failing after 24s
El despliegue hacia "docker compose down" y recien despues
"up -d --build". Si la construccion fallaba, el taller quedaba caido hasta
que alguien lo notara: paso el 03/08/2026, cuando npm no pudo bajar las
dependencias y el sitio estuvo abajo sin que ningun paso lo avisara.

Ahora la imagen se construye primero y solo despues se recrean los
contenedores, sin bajar nada de antemano. Si la construccion falla, lo que
estaba corriendo sigue en pie.

Se agrega ademas una verificacion final: si al terminar no hay contenedores
corriendo, el despliegue falla y muestra los registros, en lugar de dar por
bueno un servicio caido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:49:17 -03:00
jpbeldomenico 8d1b5da448 Merge pull request 'ci: revisar los pull requests con IA' (#1) from feat/ai-review into main
Deploy / deploy (push) Failing after 1m31s
2026-08-03 19:27:55 -03:00
jpbeldomenico 5e027d2c8c feat(ci): revisar los pull requests con IA
Revisión con IA / ai-review (pull_request) Successful in 1m58s
Replica en este repositorio la revision automatica que ya corre en
prode_app: al abrir, reabrir o actualizar un pull request, un modelo lee el
diff completo y publica comentarios pegados a cada linea mas un resumen,
firmados por el usuario ai-bot.

El modelo es gratuito y cada revision cuesta dos pedidos, porque manda todos
los archivos juntos en lugar de uno por archivo.

Los secretos se leen de Infisical al arrancar el job, asi que en el
repositorio solo quedan las credenciales de la identidad de maquina.

El prompt esta escrito sobre este proyecto en particular: turnos que se
pisan, husos horarios, autorizacion distinta para admin, jefe y cliente,
magic links tratados como credenciales, migraciones que acompañen a
schema.prisma y datos personales que no terminen en los registros.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:55:10 -03:00
12 changed files with 562 additions and 57 deletions
+60
View File
@@ -0,0 +1,60 @@
# 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
@@ -0,0 +1,68 @@
# 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
@@ -0,0 +1,23 @@
# 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
@@ -0,0 +1,244 @@
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
+26 -2
View File
@@ -10,9 +10,33 @@ 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
docker compose up -d --build # Construir ANTES de tocar lo que esta corriendo. Antes este paso
# 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
+27
View File
@@ -24,6 +24,10 @@ 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;
@@ -143,6 +147,8 @@ 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",
@@ -187,6 +193,7 @@ 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");
@@ -225,6 +232,26 @@ 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);
} }
+10 -3
View File
@@ -1,13 +1,20 @@
import { NextResponse } from "next/server"; 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 { whereTrabajosDeJefe } from "@/lib/servicios";
export async function GET() { // Trabajos que ofrece un jefe mecánico: los suyos + los globales del taller.
// El flujo del cliente es jefe → trabajo, así que el jefe es obligatorio.
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); 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 jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId");
if (!jefeMecanicoId)
return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 });
const servicios = await prisma.tipoServicio.findMany({ const servicios = await prisma.tipoServicio.findMany({
where: { activo: true }, where: whereTrabajosDeJefe(jefeMecanicoId),
orderBy: { nombre: "asc" }, orderBy: { nombre: "asc" },
}); });
+3
View File
@@ -2,6 +2,7 @@ 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"); const sesion = await verificarSesion("cliente_session");
@@ -18,6 +19,8 @@ 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);
+65 -46
View File
@@ -20,7 +20,9 @@ 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"];
type Step = "servicio" | "jefe" | "calendario" | "confirmado"; // El cliente elige primero el mecánico y después el trabajo, porque cada jefe
// carga sus propios trabajos (además de los globales del taller).
type Step = "jefe" | "servicio" | "calendario" | "confirmado";
function formatDuracion(h: number) { function formatDuracion(h: number) {
if (h < 1) return `${Math.round(h * 60)} min`; if (h < 1) return `${Math.round(h * 60)} min`;
@@ -66,12 +68,13 @@ function TurnosContent() {
const params = useSearchParams(); const params = useSearchParams();
const autoId = params.get("autoId"); const autoId = params.get("autoId");
const [step, setStep] = useState<Step>("servicio"); const [step, setStep] = useState<Step>("jefe");
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(false); const [loadingJefes, setLoadingJefes] = useState(true);
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[]>([]);
@@ -90,7 +93,12 @@ function TurnosContent() {
useEffect(() => { useEffect(() => {
if (!autoId) { router.push("/cliente/dashboard"); return; } if (!autoId) { router.push("/cliente/dashboard"); return; }
fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios); fetch("/api/cliente/jefes")
.then(r => r.json())
.then((data) => {
setJefes(Array.isArray(data) ? data : []);
setLoadingJefes(false);
});
fetch("/api/cliente/turnos") fetch("/api/cliente/turnos")
.then(r => r.json()) .then(r => r.json())
.then((data) => { .then((data) => {
@@ -110,29 +118,30 @@ function TurnosContent() {
return m; return m;
}, [slots]); }, [slots]);
async function seleccionarServicio(s: TipoServicio) { async function seleccionarJefe(j: Jefe) {
setServicioSel(s); setJefeSel(j);
setJefeSel(null); setServicioSel(null);
setServicios([]);
setSlots([]); setSlots([]);
setDiaSeleccionado(null); setDiaSeleccionado(null);
setSlotSel(null); setSlotSel(null);
setLoadingJefes(true); setLoadingServicios(true);
setStep("jefe"); setStep("servicio");
const res = await fetch("/api/cliente/jefes"); const res = await fetch(`/api/cliente/servicios?jefeMecanicoId=${j.id}`);
const data = await res.json(); const data = await res.json();
setJefes(Array.isArray(data) ? data : []); setServicios(Array.isArray(data) ? data : []);
setLoadingJefes(false); setLoadingServicios(false);
} }
async function seleccionarJefe(j: Jefe) { async function seleccionarServicio(s: TipoServicio) {
if (!servicioSel) return; if (!jefeSel) return;
setJefeSel(j); setServicioSel(s);
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=${servicioSel.id}&jefeMecanicoId=${j.id}`); const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}&jefeMecanicoId=${jefeSel.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);
@@ -213,14 +222,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"
@@ -236,33 +245,46 @@ 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("jefe")}>←</Button>
) : step === "jefe" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button> <Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : step === "servicio" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : ( ) : (
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link> <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 === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`} {step === "servicio" && `Trabajos de ${jefeSel?.nombre}`}
{step === "calendario" && `${jefeSel?.nombre}${servicioSel ? ` · ${servicioSel.nombre}` : ""}`}
</h1> </h1>
</div> </div>
{/* STEP: servicio */} {/* STEP: jefe */}
{step === "servicio" && ( {step === "jefe" && (
<> <>
<div className="grid gap-3"> <div className="flex flex-col gap-3">
{servicios.map(s => ( {loadingJefes ? (
<button key={s.id} onClick={() => seleccionarServicio(s)} <p className="text-center text-sm text-gray-200">Cargando jefes...</p>
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"> ) : jefes.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 text-center">
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
</p>
</Card>
) : (
jefes.map((j) => (
<button
key={j.id}
onClick={() => seleccionarJefe(j)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
>
<div> <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 sus trabajos</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 && (
@@ -288,27 +310,24 @@ function TurnosContent() {
</> </>
)} )}
{/* STEP: jefe */} {/* STEP: servicio */}
{step === "jefe" && ( {step === "servicio" && (
<div className="flex flex-col gap-3"> <div className="grid gap-3">
{loadingJefes ? ( {loadingServicios ? (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p> <p className="text-center text-sm text-gray-200">Cargando trabajos...</p>
) : jefes.length === 0 ? ( ) : servicios.length === 0 ? (
<Card> <Card>
<p className="text-sm text-gray-600 text-center"> <p className="text-sm text-gray-600 text-center">
No hay jefes mecánicos disponibles en este momento. Intentá más tarde. {jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico.
</p> </p>
</Card> </Card>
) : ( ) : (
jefes.map((j) => ( servicios.map(s => (
<button <button key={s.id} onClick={() => seleccionarServicio(s)}
key={j.id} 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">
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 disponibilidad</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>
+24
View File
@@ -0,0 +1,24 @@
import type { Prisma, TipoServicio } from "@prisma/client";
/**
* Regla única de qué trabajos ofrece un jefe mecánico.
*
* Los jefes cargan sus propios trabajos desde `/jefe/servicios` (`jefeMecanicoId` = su id),
* y el admin puede cargar trabajos globales del taller (`jefeMecanicoId` = null) que ofrece
* cualquiera. El cliente elige primero el mecánico y después el trabajo, así que la lista
* siempre es: los del jefe + los globales.
*/
export function whereTrabajosDeJefe(jefeMecanicoId: string): Prisma.TipoServicioWhereInput {
return {
activo: true,
OR: [{ jefeMecanicoId }, { jefeMecanicoId: null }],
};
}
/** Un trabajo propio de otro jefe no se puede reservar con este. Los globales sí. */
export function trabajoEsDeJefe(
servicio: Pick<TipoServicio, "jefeMecanicoId">,
jefeMecanicoId: string
): boolean {
return servicio.jefeMecanicoId === null || servicio.jefeMecanicoId === jefeMecanicoId;
}
+5
View File
@@ -1,5 +1,6 @@
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;
@@ -30,6 +31,10 @@ 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);
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId); const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId);
+3 -2
View File
@@ -3,6 +3,7 @@ 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 };
@@ -96,7 +97,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: { jefeMecanicoId: datos.jefeMecanicoId, activo: true }, where: whereTrabajosDeJefe(datos.jefeMecanicoId!),
orderBy: { nombre: "asc" }, orderBy: { nombre: "asc" },
}); });
@@ -303,7 +304,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) { if (!servicio || !servicio.activo || !trabajoEsDeJefe(servicio, datos.jefeMecanicoId!)) {
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]);
} }