91112862a5
Revisión con IA / ai-review (pull_request) Successful in 29s
- crearTurno verificaba disponibilidad y creaba el turno en dos operaciones sueltas: dos clientes podian pasar la verificacion a la vez y sobrevender el mismo horario. Ahora va todo en una transaccion, serializada por jefe con pg_advisory_xact_lock. slotEstaDisponible acepta el cliente de la transaccion. Probado contra un Postgres real: 5 reservas simultaneas del mismo slot con capacidad 1 dan 1 ok + 4 conflictos y una sola fila; sacando el lock, entran las 5. - La deduplicacion del webhook estaba en memoria y solo servia con una instancia. Pasa a la base con el modelo MensajeWhatsappProcesado: el insert del id es atomico por el indice unico, asi que aguanta reintentos concurrentes de Meta (verificado). Se limpian los ids de mas de 7 dias. - postMensaje no reintentaba: ahora reintenta 3 veces ante 429, 5xx y fallas de red, con backoff exponencial y jitter. Un 4xx nuestro no se reintenta. - /api/cliente/servicios devolvia los trabajos de un jefe dado de baja; ahora responde 404 si el jefe no existe o no esta activo. test:envio suma los casos de reintento (35 asserts). El Prisma en memoria del test del bot ahora entiende $transaction y $executeRaw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
4.3 KiB
Plaintext
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])
|
|
}
|