← Volver a proyectos

Proyecto 06 / Caso de estudio

Backend Java · React · Android

Que la partida se llene sola.

Coordinar una mesa de rol es un problema de horarios, no de voluntad. Un servicio donde cada jugador declara cuándo puede, el sistema busca las franjas que coinciden con el mayor número de personas y avisa antes de que se olviden. API en Java con modelado de dominio, esquema versionado y aplicación Android instalable que escribe en el calendario del móvil.

37endpoints REST
11entidades y tablas
18claves foráneas
1.877líneas de Java
RolDiseño, desarrollo y despliegue
ClientesWeb React y APK Android
TecnologíasJava, Spring Boot, PostgreSQL, Flyway, React, Capacitor
01

El reto

Encuestarlo a mano funciona hasta cuatro personas. A partir de ahí se pierde en cadenas de chat: franjas que nadie respondió, cambios de última hora, avisos que llegan tarde y un coordinador que acaba haciendo de secretaria. El problema real no era hacer una agenda, era hacer coincidir disponibilidad sin que nadie tuviera que releer diecisiete mensajes.

02

La solución

Un grupo con miembros y roles. Cada uno registra franjas de disponibilidad y la API calcula cuáles coinciden, ordenadas por cuánta gente puede asistir. Sobre una franja se abre una votación con opciones y confirmación explícita. Las sesiones tienen reglas de aviso con anticipación y repetición, y un despachador que las reparte. El mismo frontend se sirve en la web y se empaqueta como APK.

03

El aprendizaje

Que el aislamiento entre clientes de un mismo servicio se diseña en el mapa de rutas, no dentro de las consultas. Y que una regla de aviso mal expresada se convierte en ruido: separar el «cuánto antes» del «cada cuánto» como datos del grupo es lo que permite que el sistema no moleste.

Modelo de datos

El grupo como frontera, y la base como contrato.

Once entidades y once tablas con dieciocho claves foráneas y seis restricciones de unicidad, creadas por migraciones versionadas. Todo cuelga del grupo, y las rutas son anidadas para que la pertenencia se compruebe en el camino.

Las rutas son anidadas —/api/groups/{groupId}/availability, /api/groups/{groupId}/sessions/{sessionId}— de modo que el grupo se resuelve y se autoriza antes de llegar al dato. Las dieciocho claves foráneas son las que garantizan que no exista una disponibilidad huérfana, una sesión sin grupo o un voto colgado de una opción borrada.

El esquema inicial crea las once tablas: usuarios, grupos, membresías con su rol, franjas de disponibilidad, sesiones, tres tablas de votación —pregunta, opción, voto—, reglas de aviso y entregas de notificación. La segunda migración no añade tabla: añade la columna google_subject a app_users con su restricción de unicidad, que es lo que ata una identidad de Google a una cuenta sin duplicar la persona.

Los tokens de sesión no se guardan en claro: se almacena el hexadecimal de su SHA-256 y se busca por hash en cada petición, con caducidad configurable. El envío de avisos pasa por una tabla de entregas, así que un recordatorio duplicado se detecta leyendo la base en lugar de interrogar al servidor de correo.

app_users ─┐ ├─ group_members (rol) ─ role_groups ─┬─ availabilities (18 FK) ├─ game_sessions ── notification_rules ├─ session_polls ─ poll_options ─ poll_votes └─ notification_deliveries api_tokens token_guardado = SHA-256(token) · caduca V1__initial_schema.sql 11 tablas · 18 referencias · 6 unicidades V2__google_accounts.sql app_users.google_subject + UNIQUE
  • Datos

      PostgreSQLFlywayH2 en pruebas18 claves foráneas
  • Backend

      JavaSpring BootSpring SecurityTokens opacos con hash SHA-256Transacción por endpoint
  • Frontend

      React 18TypeScript 5.7Vite 6pnpm
  • Móvil

      CapacitorCalendario del sistemaNotificaciones localesAPK de depuración
  • Correo

      SMTPMailpit en desarrolloEntregas registradas

Reglas de negocio

Avisar a tiempo sin convertirse en ruido.

Un recordatorio que llega mal es peor que ninguno: educa a ignorar al sistema. Las reglas de aviso son datos del grupo, no constantes del código, y el despachador calcula el ciclo en bloques enteros.

Cada grupo nace con una regla por defecto: avisar 1.440 minutos antes —un día— y repetir cada 1.320 —veintidós horas—. El resultado práctico es un recordatorio diario hasta que la sesión se confirma, con margen para responder. Cambiarlo no toca código: es una fila.

Hay dos validaciones que están escritas a propósito. Una franja de disponibilidad dura entre treinta minutos y ocho horas, y las que cruzan la medianoche se normalizan sumando un día antes de comparar. Y el intervalo de repetición no puede ser mayor que la anticipación del aviso, porque si no la segunda repetición caería antes que la primera.

El despachador no guarda un temporizador por sesión: calcula la hora de cada entrega a partir de la hora de inicio. Primero mira la mayor anticipación entre todas las reglas del grupo y barre solo las sesiones que caen en esa ventana más cinco minutos. Después, para cada regla, proyecta el ciclo con división entera.

Instant first = session.getStartsAt() .minus(rule.getLeadMinutes(), MINUTES); if (rule.getRepeatMinutes() == null) return first; long elapsed = Duration .between(first, now).toMinutes(); return first.plus( (elapsed / rule.getRepeatMinutes()) * rule.getRepeatMinutes(), MINUTES);

Un build, dos destinos

La app no es un segundo backend.

El mismo frontend se sirve en el navegador y se empaqueta como APK de Android. Eso obliga a separar con precisión lo que es del servidor de lo que es del dispositivo.

React y TypeScript sobre Vite, con Capacitor empaquetando a Android. El APK escribe en el calendario del sistema y programa notificaciones locales: el aviso sale con la aplicación cerrada y el evento queda en la agenda que la persona ya consulta. En la web no hay calendario del sistema que tocar, así que el mismo botón descarga un archivo .ics con las sesiones sin cancelar.

Los recordatorios del móvil no los empuja el servidor: la aplicación lee las mismas reglas de la API y proyecta el ciclo entero hacia el futuro, una notificación por momento, saltando las sesiones empezadas o canceladas. El identificador de cada una sale de hashear grupo · sesión · regla · momento, así que resincronizar al mismo grupo no genera duplicados. Y hay un techo de doscientas alarmas programadas, porque Android las admite y una mesa con muchas sesiones te come la cola del sistema.

Dos detalles que solo se escriben después de leerse la documentación de Android: hay que pedir el permiso de alarma exacta aparte del permiso de notificar —si no, el sistema agrupa los avisos y llegan tarde—, y al cambiar una regla hay que cancelar las pendientes de ese grupo antes de volver a programar, filtrando por el identificador del grupo que la propia app guardó en la carga extra de cada aviso.

El servidor no se entera de ninguna de las dos cosas. La API es idéntica para el navegador que para el móvil, así que la app es un cliente más y no una segunda fuente de verdad que mantener sincronizada.

En desarrollo, el SMTP apunta a Mailpit en el puerto 1025: los mensajes se construyen y se reciben de verdad, pero ninguno sale a internet. Se puede repetir un flujo de avisos completo sin bombardear buzones reales, y esa comodidad es la que mantiene el correo bajo prueba.

  • Superficie de la API

      Auth · 6Group · 10Poll · 5Availability · 4Session · 4NotificationRule · 4User · 3Health · 1
  • Transversal

      Manejador de errores globalTransacción declarada en el controladorRol de administrador exigido en la ruta
Decisión

Rutas anidadas por grupo en lugar de recursos planos

Un /api/availability/{id} plano obliga a recordar, en cada método, filtrar por el grupo del usuario autenticado. Con rutas anidadas, la pertenencia se valida en el enrutamiento. El coste es que las URLs crecen y hay que resolver el grupo en cada controlador. El beneficio es que un error de aislamiento entre clientes deja de ser una consulta olvidada y pasa a ser una ruta que simplemente no existe.

Verificación

Siete pruebas que arrancan la aplicación entera.

Un único fichero de integración levanta el contexto completo contra una base en memoria y recorre los flujos por HTTP, como lo haría un cliente. Es una red de seguridad para refactorizar, no una cobertura del dominio.

Lo que está cubierto: el administrador sembrado lista su grupo, una disponibilidad se guarda y se vuelve a leer, un administrador añade un miembro y rota la coordinación, las peticiones sin credencial se rechazan, una cuenta normal se registra y arranca una sesión, una credencial de Google válida crea la cuenta la primera y la reutiliza la segunda, y las coincidencias de partidas mensuales mantienen separados los huecos adyacentes y muestran quién falta.

Esa última prueba es la que más vale del fichero: el fallo fácil de un buscador de coincidencias es fusionar dos franjas contiguas en una sola, o contar como disponible a quien no respondió. Está nombrada exactamente para eso.

La diferencia de motor es un riesgo conocido. Las pruebas corren contra H2 en modo compatibilidad con PostgreSQL y con Flyway desactivado —Hibernate crea el esquema de tanto en tanto—. Dos consecuencias: la migración real no se ejercita nunca en las pruebas, y un tipo admitido por H2 en ese modo puede no serlo por el PostgreSQL de verdad. La corrección es un perfil de prueba con PostgreSQL contenedorizado, que es una tarea de horas y no la he hecho.

Y falta lo que no se ve desde un endpoint: el ciclo de repetición de los avisos y la transición de estados de una votación. Un recordatorio que se dispara dos veces o una partida que se confirma desde un estado que no debería no lo rompe ninguna de las siete.

  • 37endpoints en 8 controladores y un manejador global de errores
  • 11entidades, 11 tablas y 18 claves foráneas
  • 2migraciones versionadas: esquema y enlace con la identidad de Google
  • 7pruebas de integración sobre H2 en modo PostgreSQL
  • 1.440 / 1.320minutos de aviso y repetición por defecto
  • 1.877líneas de Java y 928 de TypeScript

Lo que haría distinto

Pruebas de dominio antes que funciones nuevas. El ciclo de repetición de los avisos y la transición de estados de una votación no tienen ni una prueba, y son justo lo más delicado del producto: un recordatorio duplicado o una partida confirmada desde un estado improbable no lo detecta nadie hasta que se lo cuenta un jugador. Después, alinear los motores: usar PostgreSQL en las pruebas de integración, aunque cueste levantar un contenedor, porque hoy el esquema se valida contra H2 en modo compatibilidad y la migración de Flyway real no llega a ejecutarse nunca. Y un detalle de la superficie: el controlador de usuarios está restringido a administradores a nivel de clase, así que exponer ahí dentro cualquier lectura propia exige bajar la restricción a nivel de método; lo dejé así a propósito, pero es un sitio donde quien continúe se puede equivocar sin querer.