Proyectos

Ebrebook: una garantía solo existe donde la base de datos puede verla

Ebrebook es una plataforma de reservas que sirve a varios negocios sobre una sola instalación, cada uno con su subdominio. Ordena las reservas y da control sobre los huecos disponibles, con foco en negocios locales y turísticos: peluquerías, restaurantes, alquiler de material, actividades.

Producto propio, en desarrollo — sin clientes en producción todavía
Tests en la suite
178 → 195
Campos sin acotar
22en 12 formularios
Tipos de negocio
4sobre una tabla

Qué es y en qué punto está

Es un producto propio y está en desarrollo: no tiene clientes en producción todavía. Lo que sigue no es un caso de éxito, es lo que aprendí construyéndolo y, sobre todo, auditándolo con la instrucción de encontrar fallos en lugar de confirmar aciertos.

¿Qué tiene de difícil gestionar huecos disponibles?

Un calendario te dice si hay algo a esa hora. Un sistema de reservas tiene que decirte si puedes reservar, que es otra pregunta.

La diferencia está en tres cosas. Los recursos son exclusivos —una mesa, un empleado, un kayak no se pueden vender dos veces—, hay concurrencia real porque dos personas pueden pedir el mismo hueco a la vez, y los cuatro tipos de negocio del producto tienen reglas distintas: una cita ocupa a un profesional, una mesa ocupa un objeto físico, y una actividad es un contador de plazas que no es exclusivo de nadie.

¿Dónde aparece de verdad una doble reserva?

La intuición dice que es un problema de velocidad: si el sistema responde rápido, no da tiempo a que dos personas cojan el mismo hueco. Es falso.

El problema es que entre comprobar y escribir pasa tiempo. Ana pregunta si el jueves a las diez está libre y le dicen que sí. Bruno pregunta lo mismo y también le dicen que sí, porque en ese instante sigue siendo verdad. Los dos escriben. Nadie ha hecho nada mal: la verdad caducó entre la pregunta y la respuesta. Por rápido que vayas, esa ventana nunca es cero.

Las defensas que sí funcionan son dos, y son independientes. En los caminos públicos, la aplicación pide a la base de datos el control exclusivo del recurso antes de confirmar, así que la segunda petición espera su turno y al llegar ya ve el hueco ocupado. Por debajo, tres restricciones de exclusión de PostgreSQL hacen imposible almacenar dos reservas que se pisen sobre el mismo profesional, la misma mesa o el mismo recurso.

Verificado con peticiones concurrentes reales, no simuladas: mismo hueco exacto, solapamiento parcial y el caso frontera —una reserva que empieza justo cuando acaba la otra, que debe permitirse—. Los tres correctos, y el que pierde recibe un mensaje claro, no un error de servidor.

Y aun así había una doble reserva

Con esas dos defensas montadas, un restaurante podía vender la misma mesa dos veces.

Cuando un grupo no cabe en ninguna mesa, el motor junta varias. Guardaba la primera como dato en firme y las demás las escribía como texto en el campo de notas: "mesas adicionales: T3".

La restricción no podía verlo. T3 no aparecía en ninguna columna que la base de datos pudiera mirar. Y el bloqueo tampoco servía, porque no había ninguna carrera que ganar: la comprobación preguntaba por el dato en firme y respondía, sinceramente, que T3 estaba libre.

El resultado, reproducible sin concurrencia y siempre: seis personas asignadas a una mesa de dos, a la misma hora. Y cuanto más éxito tuviera el restaurante con grupos grandes, más a menudo pasaría.

El arreglo fue modelar cada mesa ocupada como una fila propia y mover la restricción ahí. La antigua se eliminó en vez de dejarla convivir: dos restricciones para el mismo invariante son dos fuentes de verdad, y la vieja era una versión estrictamente más débil.

Un invariante que quieres que garantice la base de datos tiene que vivir en una columna que la base de datos vea. En texto libre es invisible para cualquier restricción, por bien diseñada que esté.

¿Cómo se impide que un negocio toque los datos de otro?

El aislamiento de lectura estaba bien resuelto desde el principio, y por un motivo estructural: la identidad del negocio se deduce de la cuenta de quien llama, nunca de la dirección desde la que llega la petición. Un negocio no puede leer, modificar ni cancelar la reserva de otro porque ese objeto sencillamente no está en su conjunto de datos. No hay comprobación que evadir.

Un detalle que parece menor y no lo es: el error de un identificador que existe en otro negocio es idéntico byte a byte al de un identificador que no existe en ninguno. Un 403 confirmaría que el recurso existe y permitiría enumerar. Un 404 indistinguible, no.

Pero el aislamiento de escritura era otra historia, y esta la encontró la auditoría, no yo.

Los formularios de la API declaraban las referencias a empleados, servicios, mesas y recursos como campos normales. Por defecto, eso significa "cualquiera de la base de datos", sin acotar por negocio. Y los identificadores de empleado son públicos: la agenda de un negocio los expone a cualquiera que conozca su subdominio.

La cadena completa, verificada de principio a fin: leer el personal público de un competidor sin autenticarse, crear una reserva legítima en el negocio propio, y reasignarla a un empleado del competidor. La agenda de la víctima perdía cuatro huecos reales, y nada se lo notificaba. Lo mismo con bloqueos de rango arbitrario: la agenda ajena entera, de una tacada.

¿Por qué el arreglo no fue el obvio?

Dos decisiones, y la segunda es la interesante.

La primera: al buscar los campos afectados por introspección en vez de a mano, aparecieron veintidós en doce formularios, no los tres identificados. Se resolvieron con un mecanismo que engancha la construcción de campos y los acota automáticamente, no con una lista que alguien tenga que recordar mantener. Arreglar la clase de fallo, no el fallo.

La segunda: la red de seguridad obvia era añadir el negocio a las tres restricciones de exclusión. Habría sido un error. Esas restricciones comparan hoy dos reservas sobre el mismo empleado sin mirar de quién son, y por eso rechazan la reserva del atacante. Metiendo el negocio dentro de la expresión, dos reservas de negocios distintos sobre el mismo empleado dejarían de colisionar: la garantía se debilitaría en lugar de reforzarse.

La red correcta era otra: una clave foránea compuesta que hace que la base de datos rechace la incoherencia aunque se intente escribir por SQL directo, saltándose la aplicación entera.

Añadir una defensa no es lo mismo que añadir la defensa correcta. Una restricción mal elegida puede dejarte más expuesto que no ponerla, y con la sensación contraria.

¿Por qué 178 tests en verde no lo detectaron?

Esta es la parte incómoda y la que más me llevo.

Había 178 tests, todos pasando. Cubrían bien los caminos públicos con motor. Cinco de los seis hallazgos de la auditoría vivían justo donde no llegaba la cobertura: el CRUD genérico del panel, la modificación de reservas, las mesas fusionadas, los estados intermedios y el cambio de hora.

Y hay una trampa concreta que casi produce un falso aprobado en la propia auditoría. Por defecto, los tests de Django se ejecutan dentro de una transacción que se deshace al terminar, y dos hilos dentro de esa transacción no ven las escrituras del otro. Un test de concurrencia escrito así o se cuelga, o "demuestra" que no hay dobles reservas cuando sí las hay. Un verde falso es peor que no probar nada, porque publicas una afirmación apoyada en una prueba que no probaba nada.

Una suite en verde mide lo que mide. La pregunta útil no es cuántos tests hay, sino qué caminos no toca ninguno.

¿Qué hace aquí la IA, y qué no hace todavía?

La predicción de ausencias y de demanda por franja horaria está en la hoja de ruta, y hoy la respuesta honesta es que no es viable: no hay histórico. Cero reservas con desenlace conocido, cero clientes recurrentes, cero registro de ocupación.

Lo que sí está construido es la puerta que lo dice. El sistema se niega a entrenar por debajo de un umbral —quinientos desenlaces con cincuenta casos positivos para ausencias, ocho semanas y cincuenta y seis días distintos para ocupación— y devuelve una respuesta alternativa en su lugar.

Esa alternativa es una heurística de reglas con pesos fijados por criterio, no aprendidos: antelación de la reserva, si el cliente es nuevo o recurrente, si hay teléfono para avisar, si se envió recordatorio. Funciona desde el primer día sin ningún dato histórico, es explicable ante el negocio y, lo importante, va generando las etiquetas que un modelo futuro necesitará.

Es la escalera que aplico siempre: heurística primero, y solo se sube un peldaño cuando el anterior demuestra quedarse corto. Un modelo entrenado no sirve de nada el día que no hay datos, que es exactamente el día en que arranca un producto.

¿Qué falta y qué haría distinto?

Lo que sigue abierto, sin adornos: en el flujo de taller, un vehículo que entra a reparar libera su hueco y otro cliente puede reservarlo. El panel interno no comprueba solapamientos y se apoya solo en la base de datos, así que devuelve un error de servidor donde debería decir "ese hueco ya está ocupado". Reprogramar una reserva por API está roto en ambas direcciones. Y el motor trata todos los días como si tuvieran veinticuatro horas, lo que produce huecos fantasma los dos fines de semana del cambio de hora.

No existen los tiempos de limpieza o preparación entre reservas, ni la retención de un hueco mientras el cliente rellena el formulario.

Lo que haría distinto: plantear el producto desde una necesidad concreta y con demanda demostrada, en vez de construir un sistema que sirva a cuatro tipos de negocio a la vez. Los cuatro paradigmas comparten una sola tabla, y esa decisión es la que hace que un estado pensado para talleres tenga consecuencias en la disponibilidad de una peluquería.

Stack

  • Django
  • Django REST Framework
  • PostgreSQL 16
  • Restricciones GiST sobre rangos
  • Celery
  • Redis
  • Docker Compose
  • Caddy