Tema: Permisos, Sesiones, Endpoints JSON y Fetch

Hoy vamos a unir dos cosas que ya necesitamos para un sistema real: mostrar menús según el usuario logueado y consultar datos desde JavaScript usando endpoints que responden JSON.

1) Qué vamos a aprender hoy

En las clases anteriores ya vimos login, sesiones, MVC, formularios y conexión con MySQL. Hoy vamos a conectar esas piezas para entender cómo se comporta un sistema con usuarios diferentes.

Idea principal:
La vista puede tener espacios vacíos, por ejemplo <div id="resultado"></div>. Luego JavaScript consulta un endpoint, recibe JSON y pinta el HTML dentro de ese div.

2) Cómo se conecta esto con MVC

En MVC no todo se mete en un solo archivo. Cada parte tiene una responsabilidad. Para este tema lo vamos a ver así:

Parte Qué hace Ejemplo del tema
Vista Muestra el layout, sidebar, botones, tabla vacía y contenedores. views/dashboard.php
Controlador Recibe una acción, valida sesión/permisos y decide qué responder. controllers/BusquedaController.php
Modelo Hace consultas SQL y devuelve datos. models/BusquedaModel.php
JS auxiliar Consume endpoints con fetch y renderiza HTML en la vista. assets/js/busqueda.js
Vista con input y div vacío ↓ JavaScript lee lo que escribió el usuario ↓ fetch() manda petición al endpoint ↓ Controlador recibe la petición ↓ Controlador valida sesión y permisos ↓ Controlador llama al Modelo ↓ Modelo consulta la base de datos ↓ Controlador responde JSON ↓ JavaScript recibe JSON ↓ JavaScript renderiza HTML dentro del div

3) Sesiones y permisos: la base del menú

Cuando un usuario inicia sesión, normalmente guardamos información en $_SESSION. Por ejemplo:

$_SESSION['user_id'] = 1;
$_SESSION['user_nombre'] = 'Arturo';
$_SESSION['user_tipo'] = 'dueno_negocio';
$_SESSION['negocio_id'] = 3;

Pero para menús dinámicos necesitamos también saber qué permisos tiene. Por ejemplo:

$_SESSION['permisos'] = [
  'ver_productos',
  'crear_productos',
  'vender',
  'ver_reportes'
];
Regla sencilla:
Si el usuario tiene el permiso, mostramos la opción del menú. Si no lo tiene, no la mostramos.

4) Función auxiliar para revisar permisos

Para no estar escribiendo la misma validación en todas partes, conviene crear una función. La podemos llamar hasPermission().

Ejemplo helpers/permission_helper.php

Qué parte es esto: es un helper, es decir, un archivo auxiliar. No es vista, no es modelo y no es controlador. Sirve para reutilizar funciones comunes.

<?php
// helpers/permission_helper.php

/**
 * hasPermission()
 *
 * Revisa si el usuario actual tiene un permiso específico.
 *
 * Esta función lee el arreglo $_SESSION['permisos'].
 * Si el permiso existe dentro del arreglo, regresa true.
 * Si no existe, regresa false.
 *
 * Ejemplo:
 * hasPermission('ver_productos')
 */
function hasPermission($permiso)
{
  // Si la sesión no existe, no podemos revisar permisos.
  // Regresamos false porque no hay usuario autenticado.
  if (!isset($_SESSION['permisos'])) {
    return false;
  }

  // in_array busca un valor dentro de un arreglo.
  // Si el permiso está en la lista, el usuario sí puede usar esa sección.
  return in_array($permiso, $_SESSION['permisos']);
}

/**
 * requirePermission()
 *
 * Esta función sirve para proteger controladores o endpoints.
 *
 * Si el usuario no tiene el permiso requerido, se detiene el script.
 * Para endpoints JSON, normalmente responderíamos JSON con error.
 */
function requirePermission($permiso)
{
  if (!hasPermission($permiso)) {
    http_response_code(403);

    echo json_encode([
      'ok' => false,
      'msg' => 'No tienes permiso para realizar esta acción.'
    ]);

    exit;
  }
}

5) Vista: sidebar con menú según permisos

El sidebar es parte de la vista, porque es HTML que el usuario ve. Pero ese HTML puede tener condiciones en PHP para decidir qué opciones se muestran.

Esta vista usa:

Vista views/dashboard.php
<?php
// views/dashboard.php

// La vista necesita sesión para leer los datos del usuario.
// También necesita el helper para revisar permisos.
if (session_status() === PHP_SESSION_NONE) {
  session_start();
}

require_once __DIR__ . '/../helpers/permission_helper.php';

// Este ejemplo asume que el login ya guardó datos en $_SESSION.
// Si no existe user_id, mandamos a login.
if (!isset($_SESSION['user_id'])) {
  header('Location: /login.php');
  exit;
}
?>
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Dashboard</title>

  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>

<body class="bg-light">

<div class="d-flex">

  <!-- SIDEBAR: esto es parte de la Vista -->
  <aside class="bg-dark text-white p-3" style="width: 260px; min-height: 100vh;">

    <h4 class="mb-1">Panel</h4>

    <!-- Datos del usuario logueado -->
    <div class="small text-white-50 mb-4">
      Usuario: <?php echo $_SESSION['user_nombre']; ?><br>
      Tipo: <?php echo $_SESSION['user_tipo']; ?>
    </div>

    <nav class="d-grid gap-2">

      <!-- Esta opción solo aparece si tiene permiso ver_productos -->
      <?php if (hasPermission('ver_productos')) { ?>
        <a class="btn btn-outline-light text-start" href="#" data-section="productos">
          Productos
        </a>
      <?php } ?>

      <!-- Esta opción solo aparece si tiene permiso vender -->
      <?php if (hasPermission('vender')) { ?>
        <a class="btn btn-outline-light text-start" href="#" data-section="ventas">
          Ventas
        </a>
      <?php } ?>

      <!-- Esta opción solo aparece si tiene permiso ver_reportes -->
      <?php if (hasPermission('ver_reportes')) { ?>
        <a class="btn btn-outline-light text-start" href="#" data-section="reportes">
          Reportes
        </a>
      <?php } ?>

      <!-- Esta opción solo aparece si tiene permiso ver_usuarios -->
      <?php if (hasPermission('ver_usuarios')) { ?>
        <a class="btn btn-outline-light text-start" href="#" data-section="usuarios">
          Usuarios
        </a>
      <?php } ?>

      <a class="btn btn-danger text-start mt-3" href="/logout.php">
        Cerrar sesión
      </a>

    </nav>
  </aside>

  <!-- ÁREA PRINCIPAL: también es Vista -->
  <main class="flex-grow-1 p-4">

    <h1 class="mb-2">Dashboard</h1>
    <p class="text-muted">
      Esta pantalla carga secciones usando JavaScript y endpoints JSON.
    </p>

    <!-- Buscador general -->
    <div class="card shadow-sm mb-4">
      <div class="card-body">
        <h5>Buscador general</h5>

        <!-- Este input lo leerá JavaScript -->
        <input
          type="text"
          id="txtBusqueda"
          class="form-control"
          placeholder="Escribe para buscar...">

        <div class="form-text">
          JavaScript tomará este texto, llamará un endpoint y pintará resultados abajo.
        </div>
      </div>
    </div>

    <!-- Este div se llena con JS -->
    <div id="contenidoDinamico">
      <div class="alert alert-info">
        Selecciona una opción del menú o escribe en el buscador.
      </div>
    </div>

  </main>
</div>

<!-- Este JS es auxiliar de la vista -->
<script src="/assets/js/dashboard.js"></script>

</body>
</html>
Clave:
La vista no está consultando la base de datos directamente para el buscador. Solo tiene el input y el div donde se van a pintar los resultados. El trabajo de buscar se manda a un endpoint.

6) Endpoint JSON: controlador que responde datos

Un endpoint es una URL que recibe una petición y devuelve una respuesta. En este caso no vamos a devolver HTML completo, vamos a devolver JSON.

Ejemplo de URL:

/controllers/BusquedaController.php?action=buscar&q=coca

Esta URL puede servir para buscar productos, usuarios, categorías o lo que decidamos. Por eso lo explicamos como buscador general y luego se traduce a cada módulo.

Controlador controllers/BusquedaController.php
<?php
// controllers/BusquedaController.php

/**
 * Este archivo es un CONTROLADOR.
 *
 * No imprime una página completa.
 * Recibe una acción por GET y responde JSON.
 *
 * Ejemplo:
 * /controllers/BusquedaController.php?action=buscar&q=coca
 */

header('Content-Type: application/json; charset=utf-8');

if (session_status() === PHP_SESSION_NONE) {
  session_start();
}

// Cargamos helper de permisos
require_once __DIR__ . '/../helpers/permission_helper.php';

// Cargamos el modelo que sabe consultar datos
require_once __DIR__ . '/../models/BusquedaModel.php';

// Si no hay sesión, el endpoint responde JSON con error.
// No hacemos header Location porque fetch espera JSON, no una redirección visual.
if (!isset($_SESSION['user_id'])) {
  echo json_encode([
    'ok' => false,
    'msg' => 'Sesión no iniciada.'
  ]);
  exit;
}

$action = $_GET['action'] ?? '';

$model = new BusquedaModel();

if ($action === 'buscar') {

  // Para este ejemplo pedimos permiso de ver_productos.
  // Después se puede cambiar según el módulo que se esté buscando.
  requirePermission('ver_productos');

  // q significa query o texto de búsqueda.
  $q = trim($_GET['q'] ?? '');

  if ($q === '') {
    echo json_encode([
      'ok' => true,
      'data' => [],
      'msg' => 'Sin texto de búsqueda.'
    ]);
    exit;
  }

  // El negocio_id sale de sesión para que un negocio solo vea sus datos.
  $negocioId = (int)($_SESSION['negocio_id'] ?? 0);

  // El controlador pide los datos al modelo.
  $resultado = $model->buscarGeneral($q, $negocioId);

  if ($resultado['res'] !== 'ok') {
    echo json_encode([
      'ok' => false,
      'msg' => $resultado['res']
    ]);
    exit;
  }

  $data = [];

  // Convertimos el resultado MySQL en arreglo normal para JSON.
  while ($row = $resultado['query']->fetch_assoc()) {
    $data[] = $row;
  }

  echo json_encode([
    'ok' => true,
    'data' => $data
  ]);
  exit;
}

echo json_encode([
  'ok' => false,
  'msg' => 'Acción no válida.'
]);
Muy importante:
Cuando un endpoint lo consume fetch(), conviene responder JSON. Si haces una redirección normal con header('Location'), JavaScript puede recibir HTML de login en lugar de JSON y se vuelve confuso.

7) Modelo: el archivo que consulta la base de datos

El modelo es el que habla con MySQL. El controlador no debería tener consultas SQL largas metidas directamente.

Para hacerlo general, este ejemplo busca en productos, pero la idea aplica igual para usuarios, categorías, ventas o negocios.

Modelo models/BusquedaModel.php
<?php
// models/BusquedaModel.php

/**
 * Este archivo es un MODELO.
 *
 * Su responsabilidad es hablar con la base de datos.
 * No pinta HTML.
 * No lee botones.
 * No decide cómo se ve la pantalla.
 */

require_once __DIR__ . '/../lib/Conexion.php';
require_once __DIR__ . '/../lib/Functions.php';

class BusquedaModel
{
  private $functions;

  public function __construct()
  {
    // Reutilizamos la clase Functions que ya vimos en clase.
    // Esta clase tiene el método execute($sql).
    $this->functions = new Functions();
  }

  /**
   * buscarGeneral()
   *
   * Recibe:
   * - $q: texto que escribió el usuario
   * - $negocioId: negocio actual según sesión
   *
   * Devuelve:
   * - resultado de execute()
   *
   * En este ejemplo busca productos por:
   * - nombre
   * - código
   * - descripción
   *
   * Luego se puede hacer otro método para buscar usuarios,
   * categorías, ventas, etc.
   */
  public function buscarGeneral($q, $negocioId)
  {
    // Para evitar errores simples con comillas,
    // escapamos comillas simples de forma didáctica.
    // Más adelante lo correcto sería prepared statements.
    $q = str_replace("'", "''", $q);

    $negocioId = (int)$negocioId;

    $sql = "
      SELECT
        id,
        codigo,
        nombre,
        descripcion,
        precio,
        existencia
      FROM productos
      WHERE activo = 1
        AND negocio_id = $negocioId
        AND (
          nombre LIKE '%$q%'
          OR codigo LIKE '%$q%'
          OR descripcion LIKE '%$q%'
        )
      ORDER BY nombre ASC
      LIMIT 20
    ";

    return $this->functions->execute($sql);
  }
}

8) JavaScript auxiliar: fetch y renderizado de HTML

Este archivo JS es auxiliar de la vista. No es el modelo, porque no consulta MySQL directamente. No es el controlador PHP, porque no está en el servidor. Su trabajo es:

JS auxiliar assets/js/dashboard.js
// assets/js/dashboard.js

/**
 * Este archivo ayuda a la vista.
 *
 * La vista tiene:
 * - un input con id txtBusqueda
 * - un div con id contenidoDinamico
 *
 * JavaScript lee el input, llama al endpoint y pinta resultados.
 */

document.addEventListener("DOMContentLoaded", () => {
  const input = document.getElementById("txtBusqueda");
  const contenedor = document.getElementById("contenidoDinamico");

  if (!input || !contenedor) {
    return;
  }

  let timer = null;

  input.addEventListener("keyup", () => {
    clearTimeout(timer);

    // Pequeña pausa para no llamar el endpoint en cada tecla inmediatamente.
    timer = setTimeout(() => {
      buscar(input.value);
    }, 350);
  });

  async function buscar(texto) {
    texto = texto.trim();

    if (texto === "") {
      contenedor.innerHTML = `
        <div class="alert alert-info">
          Escribe algo para buscar.
        </div>
      `;
      return;
    }

    contenedor.innerHTML = `
      <div class="alert alert-secondary">
        Buscando...
      </div>
    `;

    try {
      // encodeURIComponent evita problemas con espacios, acentos o símbolos.
      const url = "/controllers/BusquedaController.php?action=buscar&q=" + encodeURIComponent(texto);

      const response = await fetch(url);
      const json = await response.json();

      if (!json.ok) {
        contenedor.innerHTML = `
          <div class="alert alert-danger">
            ${escapeHtml(json.msg)}
          </div>
        `;
        return;
      }

      renderResultados(json.data);

    } catch (error) {
      contenedor.innerHTML = `
        <div class="alert alert-danger">
          Error al consultar el endpoint.
        </div>
      `;
    }
  }

  function renderResultados(data) {
    if (!data || data.length === 0) {
      contenedor.innerHTML = `
        <div class="alert alert-warning">
          No se encontraron resultados.
        </div>
      `;
      return;
    }

    let html = `
      <div class="card shadow-sm">
        <div class="card-body">
          <h5>Resultados</h5>

          <div class="table-responsive">
            <table class="table table-striped table-hover align-middle">
              <thead class="table-dark">
                <tr>
                  <th>Código</th>
                  <th>Nombre</th>
                  <th>Descripción</th>
                  <th>Precio</th>
                  <th>Existencia</th>
                </tr>
              </thead>
              <tbody>
    `;

    data.forEach(item => {
      html += `
        <tr>
          <td>${escapeHtml(item.codigo ?? "")}</td>
          <td>${escapeHtml(item.nombre ?? "")}</td>
          <td>${escapeHtml(item.descripcion ?? "")}</td>
          <td>$${escapeHtml(item.precio ?? "0.00")}</td>
          <td>${escapeHtml(item.existencia ?? "0")}</td>
        </tr>
      `;
    });

    html += `
              </tbody>
            </table>
          </div>
        </div>
      </div>
    `;

    contenedor.innerHTML = html;
  }

  /**
   * escapeHtml()
   *
   * Evita que texto recibido del servidor se interprete como HTML peligroso.
   * Es una protección básica cuando pintamos contenido usando innerHTML.
   */
  function escapeHtml(value) {
    return String(value)
      .replaceAll("&", "&amp;")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;")
      .replaceAll('"', "&quot;")
      .replaceAll("'", "&#039;");
  }
});

9) Cómo se vería el JSON que responde el endpoint

Cuando el endpoint funciona bien, puede responder algo así:

{
  "ok": true,
  "data": [
    {
      "id": "1",
      "codigo": "COCA600",
      "nombre": "Coca Cola 600 ml",
      "descripcion": "Refresco individual",
      "precio": "18.00",
      "existencia": "25.00"
    },
    {
      "id": "2",
      "codigo": "COCA2L",
      "nombre": "Coca Cola 2 litros",
      "descripcion": "Refresco familiar",
      "precio": "39.00",
      "existencia": "10.00"
    }
  ]
}

Si algo falla, puede responder:

{
  "ok": false,
  "msg": "No tienes permiso para realizar esta acción."
}
Clave para entender endpoints:
El endpoint no tiene que verse bonito. El endpoint entrega datos. Quien se encarga de pintarlos bonito puede ser una vista PHP o JavaScript.

10) Diferencia entre renderizar con PHP y renderizar con JS

Hay dos formas comunes de pintar información.

Render con PHP

El servidor arma el HTML antes de enviarlo al navegador. Ejemplo: una vista PHP con un while que pinta una tabla.
Render con JS

La vista llega con un div vacío. Luego JS pide datos a un endpoint y construye el HTML en el navegador.
Render con PHP: Controlador → Modelo → Vista PHP ya pintada → Navegador Render con JS: Vista vacía → JS → Endpoint JSON → JS pinta HTML → Navegador

11) Cómo se conecta con permisos reales

En un sistema real no basta con ocultar opciones del menú. También hay que proteger el controlador.

Porque un usuario podría intentar entrar directo al endpoint escribiendo la URL. Por eso usamos validación doble:

  1. En la vista: ocultamos botones que no debe ver.
  2. En el controlador: bloqueamos la acción si no tiene permiso.
// En la vista:
<?php if (hasPermission('ver_productos')) { ?>
  <a href="#">Productos</a>
<?php } ?>

// En el controlador:
requirePermission('ver_productos');
Advertencia importante:
Ocultar un botón no es seguridad completa. La seguridad real debe estar también en el controlador o endpoint.

12) Resumen de responsabilidades

Hoy no se trata de memorizar archivos. Se trata de entender el recorrido: usuario → vista → JS → endpoint → controlador → modelo → base de datos → JSON → JS → vista.

13) Ejercicios para clase

  1. Explica qué diferencia hay entre ocultar un botón y proteger un controlador.
  2. Explica qué datos mínimos guardarías en sesión después del login.
  3. Escribe tres permisos para un sistema de punto de venta.
  4. Explica qué hace fetch() en este ejemplo.
  5. Explica qué parte del flujo pertenece a la vista, al controlador, al modelo y al JS auxiliar.
  6. Cambia mentalmente el buscador general para que ahora busque usuarios en lugar de productos. ¿Qué cambiaría?