Files
Beldor 91112862a5
Revisión con IA / ai-review (pull_request) Successful in 29s
fix: los cuatro hallazgos reales de la revision con IA
- crearTurno verificaba disponibilidad y creaba el turno en dos operaciones
  sueltas: dos clientes podian pasar la verificacion a la vez y sobrevender el
  mismo horario. Ahora va todo en una transaccion, serializada por jefe con
  pg_advisory_xact_lock. slotEstaDisponible acepta el cliente de la transaccion.
  Probado contra un Postgres real: 5 reservas simultaneas del mismo slot con
  capacidad 1 dan 1 ok + 4 conflictos y una sola fila; sacando el lock, entran
  las 5.
- La deduplicacion del webhook estaba en memoria y solo servia con una
  instancia. Pasa a la base con el modelo MensajeWhatsappProcesado: el insert
  del id es atomico por el indice unico, asi que aguanta reintentos
  concurrentes de Meta (verificado). Se limpian los ids de mas de 7 dias.
- postMensaje no reintentaba: ahora reintenta 3 veces ante 429, 5xx y fallas
  de red, con backoff exponencial y jitter. Un 4xx nuestro no se reintenta.
- /api/cliente/servicios devolvia los trabajos de un jefe dado de baja; ahora
  responde 404 si el jefe no existe o no esta activo.

test:envio suma los casos de reintento (35 asserts). El Prisma en memoria del
test del bot ahora entiende $transaction y $executeRaw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:21:35 -03:00

149 lines
4.3 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model Cliente {
id String @id @default(cuid())
email String? @unique
nombre String
apellido String
telefono String
telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp
createdAt DateTime @default(now())
autos Auto[]
turnos Turno[]
magicTokens MagicToken[]
conversaciones ConversacionWhatsapp[]
}
model Auto {
id String @id @default(cuid())
clienteId String
patente String @unique
marca String
modelo String
anio Int
nroChasis String?
createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id])
turnos Turno[]
historial HistorialServicio[]
}
model TipoServicio {
id String @id @default(cuid())
nombre String
duracionHoras Float
activo Boolean @default(true)
jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy)
jefeMecanico JefeMecanico? @relation(fields: [jefeMecanicoId], references: [id])
turnos Turno[]
historial HistorialServicio[]
}
model Turno {
id String @id @default(cuid())
clienteId String
autoId String?
tipoServicioId String
jefeMecanicoId String
fechaInicio DateTime
fechaFin DateTime
kilometros Int?
estado EstadoTurno @default(PENDIENTE)
comentario String?
createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id])
auto Auto? @relation(fields: [autoId], references: [id])
tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id])
jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id])
}
model JefeMecanico {
id String @id @default(cuid())
nombre String
email String @unique
passwordHash String
slotsParalelos Int @default(1)
activo Boolean @default(true)
createdAt DateTime @default(now())
turnos Turno[]
servicios TipoServicio[]
}
model HistorialServicio {
id String @id @default(cuid())
autoId String
adminId String
tipoServicioId String?
descripcion String
observaciones String?
fechaRealizado DateTime
createdAt DateTime @default(now())
auto Auto @relation(fields: [autoId], references: [id])
admin Admin @relation(fields: [adminId], references: [id])
tipoServicio TipoServicio? @relation(fields: [tipoServicioId], references: [id])
}
model Admin {
id String @id @default(cuid())
email String @unique
nombre String
passwordHash String
createdAt DateTime @default(now())
historial HistorialServicio[]
}
model MagicToken {
id String @id @default(cuid())
clienteId String
token String @unique
expiresAt DateTime
usado Boolean @default(false)
createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id])
}
enum EstadoTurno {
PENDIENTE
CONFIRMADO
CANCELADO
COMPLETADO
}
// Estado de la conversación del bot de WhatsApp, por número de teléfono.
// El webhook es stateless: acá se guarda en qué paso está y las selecciones parciales.
model ConversacionWhatsapp {
id String @id @default(cuid())
telefono String @unique // wa_id del cliente (E.164, sin '+')
paso String @default("INICIO") // paso de la máquina de estados
datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)
clienteId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cliente Cliente? @relation(fields: [clienteId], references: [id])
}
// Ids de mensajes de WhatsApp ya procesados. Meta reintenta el mismo mensaje si el
// webhook tarda o falla, y sin esto un reintento crea el turno dos veces. Va en la
// base (y no en memoria) para que siga valiendo con más de una instancia.
model MensajeWhatsappProcesado {
id String @id // wamid que manda Meta
createdAt DateTime @default(now())
@@index([createdAt])
}