Arquitectura backend para plataformas de reservas deportivas
Cómo diseñamos y construimos el backend de FutB — una plataforma de reservas de canchas deportivas con 6 roles de usuario, pagos en línea con Mercado Pago y gestión multi-complejo. Todo en producción.
El problema que resolvimos
Los complejos deportivos gestionaban reservas por WhatsApp y teléfono. Sin sistema centralizado, los dobles-turnos eran frecuentes y los pagos se hacían en efectivo al llegar. Los propietarios no tenían visibilidad de su ocupación ni control de sus trabajadores.
El desafío técnico: construir un sistema que sirva simultáneamente a múltiples tipos de usuarios con permisos radicalmente distintos, en tiempo real, con pagos integrados.
Arquitectura de roles
El primer diseño crítico fue la jerarquía de permisos. Con 6 roles distintos, la lógica de autorización tenía que ser robusta y fácil de extender.
// middleware/authorize.js
const PERMISSIONS = {
developer: ['*'],
owner: ['complex:manage', 'worker:manage', 'booking:view', 'report:view'],
admin: ['complex:validate', 'transaction:audit', 'user:ban'],
host: ['court:manage', 'attendance:mark', 'booking:view'],
player: ['booking:create', 'booking:view:own', 'payment:create'],
guest: ['booking:view:invited']
};
function authorize(permission) {
return (req, res, next) => {
const { role } = req.user;
const perms = PERMISSIONS[role] || [];
if (perms.includes('*') || perms.includes(permission))
return next();
res.status(403).json({ error: 'Sin permisos' });
};
}
Esquema de base de datos
SQLite3 fue la elección correcta para este proyecto — no hay operaciones masivas concurrentes, el volumen es manejable y el despliegue es trivial. El esquema central:
| Tabla | Propósito | Relaciones clave |
|---|---|---|
| users | Todos los usuarios del sistema | role, complex_id (nullable) |
| complexes | Complejos deportivos | owner_id → users |
| courts | Canchas individuales | complex_id → complexes |
| bookings | Reservas | court_id, player_id, host_id, payment_id |
| payments | Transacciones Mercado Pago | booking_id, mp_preference_id, status |
| guests | Jugadores invitados | booking_id, email, notification_sent |
Flujo de reserva con pago
El flujo más complejo del sistema: el jugador selecciona cancha y horario, paga en línea, y el sistema notifica al host y a los invitados automáticamente.
// routes/bookings.js — flujo completo
router.post('/', authorize('booking:create'), async (req, res) => {
const { courtId, date, timeSlot, guests } = req.body;
// 1. Verificar disponibilidad
const conflict = await db.get(
'SELECT id FROM bookings WHERE court_id=? AND date=? AND time_slot=? AND status!=?',
[courtId, date, timeSlot, 'cancelled']
);
if (conflict) return res.status(409).json({ error: 'Horario no disponible' });
// 2. Crear reserva en estado pendiente
const booking = await db.run(
'INSERT INTO bookings (court_id, player_id, date, time_slot, status) VALUES (?,?,?,?,?)',
[courtId, req.user.id, date, timeSlot, 'pending_payment']
);
// 3. Crear preferencia de pago en Mercado Pago
const preference = await mp.preferences.create({
items: [{ title: `Reserva #${booking.lastID}`, quantity: 1, unit_price: court.price }],
external_reference: booking.lastID.toString(),
notification_url: `${BASE_URL}/api/webhooks/mp`
});
res.json({ bookingId: booking.lastID, paymentUrl: preference.init_point });
});
// Webhook: confirmar pago y notificar
router.post('/webhooks/mp', async (req, res) => {
const { external_reference, status } = req.body;
if (status !== 'approved') return res.sendStatus(200);
await db.run('UPDATE bookings SET status=? WHERE id=?', ['confirmed', external_reference]);
await notifyHost(external_reference);
await notifyGuests(external_reference);
res.sendStatus(200);
});
Decisión de diseño: La reserva se crea en estado pending_payment antes del pago. Esto permite mostrarle al jugador la confirmación inmediata mientras el webhook llega. Un job limpia reservas pendientes con más de 30 minutos.
Notificaciones a invitados (Guest role)
Los jugadores invitados no tienen cuenta — reciben un email con un link único que les permite ver los detalles de la reserva sin registrarse. Implementado con tokens firmados de un solo uso.
async function notifyGuests(bookingId) {
const guests = await db.all('SELECT * FROM guests WHERE booking_id=?', [bookingId]);
for (const guest of guests) {
const token = jwt.sign(
{ guestId: guest.id, bookingId, role: 'guest' },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
await sendMail(guest.email, 'Tienes una reserva de fútbol', guestEmailTemplate(token));
await db.run('UPDATE guests SET notification_sent=1 WHERE id=?', [guest.id]);
}
}
Lo que aprendimos
SQLite escala bien para este caso de uso. Con menos de 1,000 reservas al día, los tiempos de consulta son menores a 5ms. No necesitábamos PostgreSQL.
El webhook de Mercado Pago puede llegar tarde. Implementamos un polling de respaldo que consulta el estado del pago directamente a la API de MP cada 30 segundos para reservas en estado pendiente.
Los roles deben ser inmutables en el token JWT. Aprendimos a no confiar en el rol del token para operaciones críticas — siempre verificamos contra la DB.
¿Necesitas una plataforma similar?
Construimos sistemas de reservas, pagos y gestión multi-rol a medida. Cuéntanos tu proyecto.
Solicitar cotización →