Arquitectura Node.js Caso real Mayo 2026 · 8 min de lectura

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:

TablaPropósitoRelaciones clave
usersTodos los usuarios del sistemarole, complex_id (nullable)
complexesComplejos deportivosowner_id → users
courtsCanchas individualescomplex_id → complexes
bookingsReservascourt_id, player_id, host_id, payment_id
paymentsTransacciones Mercado Pagobooking_id, mp_preference_id, status
guestsJugadores invitadosbooking_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 →