Preparación para la entrevista técnica
1 · Resumen ejecutivo
Qué pedía el test
Una mini-aplicación de gestión de usuarios con:
- Listado de usuarios con búsqueda y paginación de 10 en 10.
- Modificar usuario al pulsar sobre él (formulario en modal/página).
- Nuevo usuario mediante un botón en la cabecera.
Stack obligatorio: Bootstrap + jQuery + AJAX + PHP devolviendo JSON + MySQL. Todo testeado en XAMPP.
Qué entregaste
- Template SB Admin 2 (gratuito, Bootstrap 4) personalizado en
tables.html. - Cliente jQuery con AJAX en
js/usuarios.js(listar, paginar, buscar, crear, editar). - Backend PHP en
php/api.phpcon un único endpoint y parámetroaction. - BBDD MySQL
pruebatecnica· tablausers. - Validación de DNI español (8 dígitos + letra) en servidor.
- Validación de campos obligatorios y longitudes mínimas.
- Buscador en tiempo real sobre nombre, email, DNI y teléfono.
- Selector de avatar (4 fotos predefinidas) en los modales de crear/editar.
2 · Requisitos del PDF (palabra por palabra)
Tarea 1 · Listado de Usuarios
Listado con campos: DNI, nombre completo, fecha nacimiento, teléfono y email.
- Paginación (carga de 10 en 10).
- Buscador.
- Al seleccionar un usuario → abrir formulario de modificación.
- En la parte superior, botón "Nuevo Usuario".
Tarea 2 · Modificar Usuario
- Al pulsar un usuario → formulario con sus datos.
- Obligatorios: DNI, nombre completo y fecha de nacimiento.
- Tener en cuenta longitudes fijas: DNI, fecha y teléfono.
Tarea 3 · Nuevo Usuario
- Botón en el listado → formulario para introducir datos nuevos y guardar en BBDD.
- Mismas reglas de obligatoriedad y longitudes que en Modificar.
Metodología impuesta
- Front-end: Bootstrap (template a descargar del apartado Material).
- Back-end: AJAX con jQuery → llamada a PHP que devuelve JSON (estilo "API rest").
- Render: jQuery/JavaScript parsea el JSON y dibuja la información.
- BBDD: MySQL, tabla obligatoria
users. - Testing local: XAMPP (Apache + PHP + MySQL).
3 · Stack utilizado
El stack es el exacto que pedía la prueba. No hay framework moderno (React, Laravel...) porque el enunciado lo prohíbe implícitamente: pedía AJAX con jQuery directo contra PHP devolviendo JSON.
4 · Arquitectura de la solución
Patrón aplicado
- Cliente "SPA-light": una sola página HTML (
tables.html) que nunca se recarga; todos los cambios entran por AJAX. - Backend tipo "controlador único":
api.phprecibe un parámetroactiony enruta internamente. Es el equivalente "casero" a un router REST. - Formato de intercambio: JSON en ambas direcciones (request por
application/x-www-form-urlencodedal ser$.ajax POST, response conContent-Type: application/json).
5 · Esquema de base de datos
La tabla que da soporte a toda la aplicación:
CREATE DATABASE IF NOT EXISTS pruebatecnica
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE pruebatecnica;
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(150) NOT NULL,
dni VARCHAR(9) NOT NULL,
fecha_nacimiento DATE NOT NULL,
telefono VARCHAR(20) DEFAULT NULL,
email VARCHAR(150) DEFAULT NULL,
foto VARCHAR(100) DEFAULT 'undraw_profile.svg',
UNIQUE KEY uniq_dni (dni)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
VARCHAR(9) en DNI: porque el DNI español tiene
exactamente 8 dígitos + 1 letra = 9 caracteres. Es un campo de longitud fija, y al ponerlo
UNIQUE garantizamos a nivel de BBDD que no haya duplicados (defensa en profundidad).
CHAR(9) si es longitud fija? Respuesta:
la diferencia de rendimiento entre CHAR y VARCHAR para campos tan pequeños es
despreciable en InnoDB, y VARCHAR es más estándar. Si insistieran, CHAR(9) sería
marginalmente mejor.
6.1 · tables.html · estructura
Tomé el archivo tables.html original del template SB Admin 2 y reemplacé la tabla de ejemplo por la mía. Las piezas clave que añadí:
Buscador y botón "Nuevo Usuario"
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Listado de Usuarios</h1>
<button id="btnNuevoUsuario" class="btn btn-primary btn-icon-split">
<span class="icon text-white-50"><i class="fas fa-plus"></i></span>
<span class="text">Nuevo Usuario</span>
</button>
</div>
<div class="row mb-4">
<div class="col-12 col-md-6">
<input type="text" id="inputBuscar" class="form-control"
placeholder="Buscar por DNI, nombre, email o teléfono...">
</div>
</div>
Tabla vacía + contenedor de paginación
<table class="table table-bordered" id="tablaUsuarios">
<thead>
<tr>
<th>#</th><th>Nombre Completo</th><th>DNI</th>
<th>Fecha Nacimiento</th><th>Teléfono</th>
<th>Email</th><th>Acciones</th>
</tr>
</thead>
<tbody><!-- Las filas se cargan con AJAX --></tbody>
</table>
<div id="paginacion"></div>
Modales de Crear / Editar (mismo patrón)
- Bootstrap 4 modal con
data-toggle="modal". - Selector de avatar con
radio buttonsque envían el nombre del SVG. - Campos con
required,minlengthymaxlengthHTML5 (primera línea de defensa cliente). - Para editar, hay un
<input type="hidden" id="edit_id">que guarda el ID.
6.2 · js/usuarios.js · cliente jQuery
El archivo orquesta toda la interacción. Lo encapsulé en $(document).ready para evitar contaminar el ámbito global y para asegurarme de que el DOM existe.
Estado mínimo
let paginaActual = 1;
let terminoBusqueda = '';
Sólo dos variables porque la fuente de verdad es la base de datos. No mantengo el array de usuarios en memoria (cada cambio fuerza un re-fetch). Es simple y robusto a costa de algunas requests extras.
Función central: cargarUsuarios(page)
function cargarUsuarios(page = 1) {
paginaActual = page;
$.ajax({
url: 'php/api.php',
type: 'POST',
data: { action: 'listar', page: page, search: terminoBusqueda },
dataType: 'json',
success: function(respuesta) {
let filas = '';
respuesta.data.forEach(function(usuario, index) {
const numeroFila = (paginaActual - 1) * 10 + index + 1;
filas += `<tr>...</tr>`;
});
$('#tablaUsuarios tbody').html(filas);
generarPaginacion(respuesta.total_pages, paginaActual);
}
});
}
Event delegation
Los botones de la tabla y de la paginación se crean dinámicamente. Por eso enlazo eventos a document con delegación, no directamente al botón:
$(document).on('click', '.btn-editar', function() { ... });
$(document).on('click', '#btnAnterior', function() { ... });
$(document).on('click', '#btnSiguiente', function() { ... });
$('.btn-editar').click(...) directamente, sólo se enganchan los botones que existen
en ese instante. Cuando AJAX repinta la tabla, los nuevos botones no responden. Por eso engancho al
contenedor (document) y filtro con el selector. Esto se llama
delegación de eventos.
Buscador en tiempo real
$('#inputBuscar').on('keyup', function() {
terminoBusqueda = $(this).val().trim();
cargarUsuarios(1);
});
debounce de ~300 ms para esperar a que el usuario pare de teclear.
Lo verás en la sección 10.
6.3 · php/api.php · backend
Patrón front controller casero: un único archivo, un parámetro action, un if/else if que enruta a cada operación.
Cabecera y conexión
header('Content-Type: application/json; charset=utf-8');
$conn = new mysqli('localhost', 'root', '', 'pruebatecnica');
$conn->set_charset("utf8mb4");
$action = isset($_POST['action']) ? $_POST['action'] : '';
- UTF-8 tanto en cabecera como en la conexión (importante para tildes/ñ).
- mysqli en vez de PDO: válido para la prueba, pero PDO sería más portable.
Validación de DNI español
function esDniValido($dni) {
$dni = strtoupper(trim($dni));
if (strlen($dni) !== 9) return false;
if (!preg_match('/^[0-9]{8}[A-Z]$/', $dni)) return false;
return true;
}
TRWAGMYFPDXBNJZSQVHLCKE). La regex de arriba sólo valida el formato, no la letra real.
Una validación completa sería:
function letraDni(int $numero): string {
$letras = 'TRWAGMYFPDXBNJZSQVHLCKE';
return $letras[$numero % 23];
}
function esDniValido(string $dni): bool {
$dni = strtoupper(trim($dni));
if (!preg_match('/^(\d{8})([A-Z])$/', $dni, $m)) return false;
return letraDni((int)$m[1]) === $m[2];
}
Acción listar · paginación + búsqueda
$page = isset($_POST['page']) ? (int)$_POST['page'] : 1;
$limit = 10;
$offset = ($page - 1) * $limit;
$search = trim($_POST['search'] ?? '');
$where = '';
if ($search !== '') {
$searchEsc = $conn->real_escape_string($search);
$where = "WHERE nombre LIKE '%$searchEsc%' OR email LIKE '%$searchEsc%'
OR dni LIKE '%$searchEsc%' OR telefono LIKE '%$searchEsc%'";
}
$totalRegistros = $conn->query("SELECT COUNT(*) as total FROM users $where")
->fetch_assoc()['total'];
$totalPaginas = ceil($totalRegistros / $limit);
$sql = "SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
FROM users $where ORDER BY id DESC LIMIT $offset, $limit";
$result = $conn->query($sql);
- Casteo
(int)$_POST['page']antes de inyectarlo — protección contra SQL injection en ese campo. real_escape_stringpara el término de búsqueda.- Hago dos queries: una para el total (paginación) y otra para los 10 registros visibles.
Respuesta JSON estándar
echo json_encode([
'data' => $usuarios,
'total' => $totalRegistros,
'page' => $page,
'total_pages' => $totalPaginas
]);
Acciones crear y actualizar
Idénticas salvo por el SQL (INSERT vs UPDATE). Ambas:
- Comprueban que
nombre,dniyfecha_nacimientono están vacíos. - Validan formato de DNI.
- Si hay teléfono, comprueban longitud mínima de 9.
- Escapan cada string con
real_escape_string. - Devuelven
{success:true, message:'...'}o{error:'...'}.
7 · Decisiones técnicas y por qué
api.php con action en vez de varios archivos?Content-Type centralizadas
en un solo sitio (DRY). Hacer list.php, create.php, update.php habría
duplicado código. En un proyecto real usaría un router (Slim, Symfony) o reescritura URL, pero para una
prueba esto es lo más claro y rápido.POST para listar en vez de GET?POST + action. Lo correcto en una API REST es GET para lecturas
(idempotentes, cacheables) y POST/PUT/DELETE para escrituras. Si me lo preguntan, lo digo
tal cual: "Lo simplifiqué a un solo verbo; en REST puro usaría GET /users?page=2&search=...".fetch() y async/await, que es nativo y no requiere
librería externa.required y minlength del HTML son sólo una
conveniencia.$_FILES y
move_uploaded_file.ORDER BY id DESC?id es AUTO_INCREMENT,
DESC = orden inverso de creación.8 · Validaciones implementadas
| Campo | Cliente (HTML) | Servidor (PHP) |
|---|---|---|
| Nombre | required |
empty($nombre) + real_escape_string |
| DNI | required minlength=9 maxlength=9 |
Regex /^[0-9]{8}[A-Z]$/ |
| Fecha nacimiento | type=date required |
empty($fecha_nacimiento) (formato lo garantiza el input date) |
| Teléfono | minlength=9 maxlength=20 |
Si no está vacío → strlen < 9 rechaza |
type=email |
(no validado explícitamente — mejora pendiente) | |
| Foto | Radio button preseleccionado | Fallback a undraw_profile.svg si llega vacío |
- El email no se valida server-side.
filter_var($email, FILTER_VALIDATE_EMAIL)sería el fix. - El DNI sólo se valida en formato, no la letra de control.
- La fecha no comprueba que sea anterior a hoy ni rango plausible (1900<año<hoy).
- No hay control de DNI duplicado: si lo añado, devolveré 409 Conflict.
9 · Seguridad · vulnerabilidades y cómo las cerraría
9.1 · SQL Injection · riesgo bajo pero existe
Uso real_escape_string, lo cual protege, pero no es la mejor práctica. Lo correcto son prepared statements:
// Lo que hago ahora
$nombreEsc = $conn->real_escape_string($nombre);
$sql = "INSERT INTO users (nombre, dni, ...) VALUES ('$nombreEsc', ...)";
// Lo que debería hacer
$stmt = $conn->prepare("INSERT INTO users (nombre, dni, fecha_nacimiento, telefono, email, foto)
VALUES (?, ?, ?, ?, ?, ?)");
$stmt->bind_param('ssssss', $nombre, $dni, $fechaNacimiento, $telefono, $email, $foto);
$stmt->execute();
Por qué importa: con prepared statements el driver envía los valores por separado del SQL, por lo que nunca se pueden interpretar como sintaxis. real_escape_string depende de que no se olvide nunca y de que el charset esté bien configurado.
9.2 · XSS · vulnerabilidad real en el frontend
En usuarios.js concateno el HTML así:
filas += `<td>${usuario.nombre}</td>`;
Si un usuario malicioso (o un compañero) crea uno con nombre <script>alert(1)</script>, el script se ejecuta en el navegador de todos los que vean el listado.
Fix: escapar antes de insertar:
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
}[c]));
}
// uso:
filas += `<td>${escapeHtml(usuario.nombre)}</td>`;
O usar $.text() en vez de $.html(), o construir nodos con document.createElement.
9.3 · CSRF · no hay token
Cualquier página externa podría hacer POST a php/api.php con cookies del usuario. Fix: token CSRF en sesión, enviado en cada AJAX por header X-CSRF-Token y verificado en PHP.
9.4 · No hay autenticación
Cualquiera con acceso a la URL puede listar, editar y borrar. La prueba no lo pedía, pero en producción haría:
- Login con sesiones PHP o JWT.
- Verificación de sesión al inicio de
api.phpantes de procesar la acción. - Roles (admin/usuario) si fueran necesarios.
9.5 · Credenciales hardcodeadas
$username = 'root';
$password = '';
Problema: si subo esto a Git con credenciales reales, fuga. Fix: variables de entorno (getenv('DB_PASS')) o un archivo config.php excluido por .gitignore.
9.6 · Errores genéricos
Hoy devuelvo {"error":"Error creating user"} sin detalle. Bien para no filtrar información a un atacante, pero malo para debug. Solución: loguear el error real (error_log) y devolver un mensaje genérico al cliente.
9.7 · Cierre rápido del checklist OWASP Top 10
| OWASP | Estado | Mitigación |
|---|---|---|
| A01 Broken Access Control | vulnerable | Añadir auth |
| A03 Injection | parcial | Prepared statements |
| A05 Misconfiguration | parcial | Quitar credenciales hardcodeadas |
| A07 Auth Failures | vulnerable | Login obligatorio |
| XSS (era A07 en 2017) | vulnerable | Escape en render |
10 · Optimización y mejoras técnicas
Hoy
Cada tecla en el buscador → 1 petición HTTP.
Si tecleo "antonio" → 7 peticiones, las 6 primeras se desperdician.
Con debounce
Espero 300 ms tras la última tecla y disparo una sola petición.
function debounce(fn, ms = 300) {
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
$('#inputBuscar').on('keyup', debounce(function() {
terminoBusqueda = $(this).val().trim();
cargarUsuarios(1);
}, 300));
10.2 · Índices en BBDD para el buscador
El LIKE '%texto%' impide usar índices B-tree (porque empieza por comodín). Para tablas pequeñas no importa, pero a partir de ~50k registros se nota. Soluciones:
- Fulltext index:
ALTER TABLE users ADD FULLTEXT(nombre, email)+MATCH(...) AGAINST(...). - Migrar a buscador externo: Elasticsearch / Meilisearch para fuzzy search.
10.3 · Loaders y notificaciones decentes
Hoy uso alert(), que bloquea el hilo y es feísimo. Mejora:
// Mostrar spinner mientras carga
$('#tablaUsuarios tbody').html('<tr><td colspan="7" class="text-center"><i class="fa fa-spinner fa-spin"></i> Cargando...</td></tr>');
// En vez de alert(), un toast (SB Admin 2 trae Bootstrap toast):
function toast(msg, type='success') {
$('#toastContainer').append(`<div class="toast bg-${type}">${msg}</div>`);
}
10.4 · Validación reactiva en el formulario
Hoy el usuario sólo descubre que el DNI es inválido al enviar. Lo ideal es validar on blur del campo y marcar en rojo en cuanto pierde foco.
10.5 · Paginación más rica
Hoy sólo tengo "Anterior / Siguiente". En 30 segundos puedo añadir botones numerados:
function generarPaginacion(total, actual) {
let html = `<button id="btnAnterior" ${actual<=1?'disabled':''}>«</button>`;
for (let i = 1; i <= total; i++) {
html += `<button class="btn-page ${i===actual?'active':''}" data-page="${i}">${i}</button>`;
}
html += `<button id="btnSiguiente" ${actual>=total?'disabled':''}>»</button>`;
$('#paginacion').html(html);
}
10.6 · Una sola query para listar + total
Hoy hago dos queries: COUNT(*) y SELECT. En MySQL puedo combinarlas con SQL_CALC_FOUND_ROWS (legacy) o con una subquery, aunque para tablas pequeñas la diferencia es despreciable y la legibilidad del código actual es mejor.
10.7 · Caching del listado
Para tablas con muchos hits y pocos cambios, cachear el JSON en Redis con TTL de 30s reduce drásticamente las queries.
11 · Cómo lo refactorizaría hoy (versión moderna)
11.1 · Backend con prepared statements + PDO
// db.php
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO(
'mysql:host=localhost;dbname=pruebatecnica;charset=utf8mb4',
getenv('DB_USER') ?: 'root',
getenv('DB_PASS') ?: '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return $pdo;
}
// ListUsers.php
function listUsers(int $page, string $search): array {
$limit = 10; $offset = ($page - 1) * $limit;
$params = [];
$where = '';
if ($search !== '') {
$where = "WHERE nombre LIKE :s OR email LIKE :s OR dni LIKE :s OR telefono LIKE :s";
$params[':s'] = "%$search%";
}
$total = db()->prepare("SELECT COUNT(*) FROM users $where");
$total->execute($params);
$totalRows = (int) $total->fetchColumn();
$stmt = db()->prepare("SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
FROM users $where ORDER BY id DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return ['data' => $stmt->fetchAll(), 'total' => $totalRows,
'page' => $page, 'total_pages' => (int) ceil($totalRows / $limit)];
}
11.2 · Cliente con fetch + async/await (sin jQuery)
async function cargarUsuarios(page = 1) {
const params = new URLSearchParams({ action: 'listar', page, search: terminoBusqueda });
const res = await fetch('php/api.php', { method: 'POST', body: params });
const data = await res.json();
if (data.error) return mostrarError(data.error);
renderTabla(data.data);
renderPaginacion(data.total_pages, data.page);
}
11.3 · REST verbos correctos
| Operación | Hoy | REST correcto |
|---|---|---|
| Listar | POST api.php action=listar | GET /api/users?page=1&q=... |
| Detalle | POST api.php action=obtener | GET /api/users/{id} |
| Crear | POST api.php action=crear | POST /api/users |
| Editar | POST api.php action=actualizar | PUT /api/users/{id} |
| Borrar | (no implementado) | DELETE /api/users/{id} |
11.4 · Stack moderno alternativo
- Backend: Laravel/Lumen, Symfony, Slim, o Node.js (Express/Fastify) si TypeScript.
- Frontend: Vue 3 o React + TypeScript, con un componente
UserListyUserForm. - Estado: React Query / TanStack Query para cache, refetch automático y optimistic updates.
- UI: Mantine, shadcn/ui o Material — más profesional que un template estático.
- BBDD: sigue siendo MySQL o PostgreSQL. ORM (Eloquent/Prisma) para tipado.
12 · Banco de preguntas probables y respuestas
users con id, nombre, dni, fecha_nacimiento, telefono, email, foto.$.ajax; nativamente con fetch() o XMLHttpRequest.GET, POST, PUT, DELETE, PATCH). Cada recurso tiene una URL única (/users/42) y el verbo define la operación. Es stateless: cada petición lleva toda la info necesaria.real_escape_string y casteo a (int) los IDs/páginas. La mejor práctica son prepared statements (PDO o mysqli con bind_param), porque separan el SQL de los datos en el protocolo del driver. Es lo que pondría en producción.& → &, < → <, etc.). En jQuery, usar .text() en vez de .html(). Reconozco que en mi prueba inyecto los nombres con plantilla literal, lo cual sí es vulnerable — lo corregiría con una función escapeHtml.POST y GET?GET es para leer (idempotente, cacheable, parámetros visibles en la URL). POST es para crear o modificar (no idempotente por defecto, body separado, no cacheable). En mi prueba uso POST para todo por uniformidad, pero en REST GET sería el verbo correcto para listar.$(document).ready() y por qué?<script> al final del body o con defer..btn-editar y los de paginación, que se recrean con cada $.html().- Migrar a prepared statements y validación robusta (sí o sí).
- Índice en
dniy un índice fulltext ennombre, emailpara el buscador. - Paginación por cursor (
WHERE id < ?) en vez deOFFSET, que se vuelve lento con páginas profundas. - Cache de Redis para las queries más comunes.
- Compresión gzip + HTTP/2.
- Servidor PHP en un pool con balanceador (HAProxy/Nginx) y MySQL en réplica master/slave para escalar lecturas.
- Buscador externo (Meilisearch / Elastic) si el LIKE deja de rendir.
utf8mb4 y no utf8?utf8 de MySQL es un alias engañoso: sólo soporta hasta 3 bytes por carácter, por lo que no soporta emojis ni algunos caracteres chinos. utf8mb4 es el UTF-8 real, hasta 4 bytes. Siempre utf8mb4.- Backend: PHPUnit. Test unitarios para
esDniValido()(con casos buenos y malos), tests de integración contra una BBDD de test que se reinicia con fixtures. - Frontend: Jest + Testing Library, o Cypress para tests end-to-end (login, crear usuario, buscar, paginar).
- API: Postman collections o tests con
guzzlehttp/guzzleen PHP.
- Añado un botón
btn-borraren cada fila condata-id. - Listener delegado al
documentcon confirmación (SweetAlertidealmente,confirm()mínimo). - Nueva acción
borrarenapi.phpconDELETE FROM users WHERE id = ?(prepared). - Tras éxito, recargo el listado (
cargarUsuarios(paginaActual)). - Plus: soft delete con una columna
deleted_aten vez de borrar físico, para poder restaurar.
mysqli y PDO?mysqli sólo habla MySQL. PDO es una abstracción sobre múltiples drivers (MySQL, PostgreSQL, SQLite...). PDO es más portable y tiene una API más limpia para prepared statements. mysqli ofrece algunos métodos específicos de MySQL ligeramente más rápidos. En proyectos nuevos suelo usar PDO.interface Usuario compartida cliente-servidor (con OpenAPI o un generador).main. En la prueba no inicialicé repo, pero en un proyecto real haría git init desde el minuto cero, con un .gitignore que excluya vendor/, node_modules/ y archivos con credenciales.Access-Control-Allow-Origin. En mi prueba no hay CORS porque cliente y servidor están en el mismo origen (localhost). Aparecería si separase el front en otro dominio.13 · Cómo presentar el código en vivo
- Arrancar XAMPP: Apache + MySQL en verde. Abrir
http://localhost/pruebatecnica/tables.html. - Mostrar el listado: 10 registros, paginación abajo, búsqueda arriba.
- Buscar: teclear "antonio" y comentar: "Lo correcto sería un debounce de 300 ms; lo añadiría en producción".
- Paginar: ir a página 2, mostrar que la numeración de la primera columna sigue correcta (
(página-1)*10 + index + 1). - Editar: abrir un usuario, cambiar el teléfono, guardar. Mostrar que se actualiza sin recargar.
- Crear: intentar guardar con DNI inválido para enseñar la validación.
- Abrir DevTools → Network: repetir una acción y mostrar el JSON de ida y vuelta. Esto demuestra que entiendes lo que pasa "por debajo".
- Abrir el código: abrir
api.phpen la sección de validación de DNI y explicar la regex. - Cerrar con una mejora: "Lo siguiente que añadiría sería X" (debounce, prepared statements o auth). Demuestra visión.
14 · Cheatsheet · lo último que repasarás
Archivos modificados
tables.htmljs/usuarios.jsphp/api.php
BBDD
- db:
pruebatecnica - tabla:
users - charset:
utf8mb4
Acciones API
listar(page, search)obtener(id)crear(datos)actualizar(id, datos)
Validaciones
- DNI: regex
/^\d{8}[A-Z]$/ - Obligatorios: nombre, dni, fecha
- Teléfono mín. 9
3 mejoras que siempre mencionar
- Prepared statements (PDO).
- Escape XSS en el render.
- Debounce del buscador.
3 conceptos que tienes que dominar
- Event delegation.
- Por qué validar en cliente y servidor.
- Idempotencia de los verbos HTTP.
- Si no sabes algo, dilo y explica cómo lo investigarías. Es la mejor respuesta posible.
- Cuando expliques código, habla del por qué más que del qué.
- Las mejoras que reconoces voluntariamente antes de que te las señalen suman muchísimo.
- Pregunta tú también: por el equipo, el stack real, los retos de la posición. Demuestra interés.
Mucha suerte mañana. Estás preparado.
15 · Discurso paso a paso · "Cuéntame qué has hecho"
1 · Abre con el problema, no con el código ~15 s
"El test pedía una mini-aplicación de gestión de usuarios con tres operaciones: listar usuarios con paginación y búsqueda, modificarlos al pulsar sobre uno, y crear nuevos desde un botón en la cabecera. El stack venía marcado: Bootstrap en el front, jQuery con AJAX como cliente, PHP devolviendo JSON, MySQL detrás y XAMPP en local."
2 · Cuenta la arquitectura general antes del detalle ~20 s
"Lo planteé como un SPA-light: una sola página HTML, tables.html, que no se recarga nunca.
Todos los cambios entran por AJAX contra un único endpoint PHP, api.php, que actúa como
front controller: recibe un parámetro action con valores listar,
obtener, crear o actualizar y enruta internamente.
Por debajo, MySQL con una única tabla users."
3 · Base de datos en una frase ~15 s
"La tabla users tiene seis campos: nombre, dni, fecha_nacimiento,
telefono, email y foto, más el id autoincremental.
Puse dni como UNIQUE a nivel de BBDD como defensa en profundidad,
por si la validación de la aplicación fallara. Charset utf8mb4 para soportar tildes y emojis."
utf8mb4 y el UNIQUE son los que se quedan grabados.
Si te dicen "¿por qué utf8mb4?", ya tienes la respuesta lista en la sección 12.
4 · Backend · cómo funciona api.php ~30-40 s
"En el backend, api.php empieza fijando la cabecera Content-Type: application/json
y abre la conexión mysqli. Lee action de $_POST y entra en un switch lógico.
La acción más interesante es listar: recibe page y search.
Casteo page a int para que no me puedan inyectar SQL por ese parámetro,
y al término de búsqueda le aplico real_escape_string. Construyo una WHERE dinámica
con LIKE sobre nombre, email, DNI y teléfono. Hago dos queries: una para el
COUNT(*) (para calcular el total de páginas) y otra para los 10 registros visibles, ordenados
por id DESC para que los recién creados aparezcan arriba.
Para crear y actualizar valido server-side los obligatorios (nombre,
dni, fecha), el formato del DNI con regex (^\d{8}[A-Z]$) y la longitud
mínima del teléfono. La respuesta siempre es JSON con success o error."
5 · Frontend · cómo orquesta usuarios.js ~30-40 s
"En el cliente, usuarios.js está envuelto en $(document).ready para asegurar que el DOM existe.
Mantengo solo dos variables de estado: la página actual y el término de búsqueda.
La fuente de verdad es la BBDD: cada cambio dispara un re-fetch, así no me complico sincronizando un estado en memoria.
La función central es cargarUsuarios(page): hace $.ajax POST a api.php
con action: 'listar', recibe el JSON, y dibuja las filas concatenando un template literal.
Los botones de la tabla y de la paginación se crean dinámicamente, así que uso
event delegation: engancho el listener al document y filtro por selector.
Si no, los botones nuevos no responderían tras un repintado AJAX.
El buscador dispara una petición en cada keyup. Es un punto que mencionaré en mejoras:
lo correcto sería un debounce de 300 ms."
6 · Cierra con mejoras voluntarias ~15-20 s
"Funciona, pero soy consciente de tres mejoras importantes que haría en producción:
Primero, prepared statements con PDO en vez de real_escape_string,
porque separan el SQL de los datos en el protocolo del driver y no dependen de que no se te olvide nunca.
Segundo, escapar el HTML antes de inyectarlo en la tabla, porque si un usuario malicioso
guardara <script>alert(1)</script> como nombre, se ejecutaría en el navegador
de cualquiera que abriera el listado. Es un XSS clásico.
Y tercero, el debounce del buscador que te decía, y validación de email con
filter_var(..., FILTER_VALIDATE_EMAIL) que ahora no tengo. ¿Quieres que entremos en alguno de estos puntos?"
Tips de entrega
Tono y ritmo
- Habla más despacio de lo que crees. Los nervios aceleran.
- Si te oyes pausado, vas bien.
- Después de cada bloque, respira 1 segundo. Da tiempo al entrevistador a interrumpir si quiere.
Vocabulario que suma
Suelta estas palabras cuando puedas — cada una compra credibilidad:
- "Déjame mirar el código un momento para no inventarme un detalle." Mejor que decir algo dudoso.
- "No estoy 100% seguro de eso. Lo que yo haría sería buscarlo en la documentación oficial." Demuestra honestidad y autonomía.
- Técnico y lo sabes → respondes y vuelves al hilo.
- Técnico y no lo sabes → "No lo tengo fresco, pero por lo que entiendo es X, lo confirmaría en la doc." Nunca inventes; las mentiras se notan.
- De equipo / proceso → cuéntalo en presente con un ejemplo concreto.
Versión ultra-corta · 30 segundos
Si en algún momento te piden el resumen rápido (pasilleo, presentación inicial, "en una frase"...):
"Una mini-app de usuarios con listado paginado, búsqueda en tiempo real, crear y editar.
Frontend en tables.html con Bootstrap (SB Admin 2) y usuarios.js con jQuery + AJAX.
Backend en api.php, un único endpoint con parámetro action que enruta a cada
operación y devuelve JSON. MySQL con tabla users y dni único.
Validación cliente y servidor del DNI español.
Mejoras que haría: prepared statements, escape XSS al pintar,
y debounce en el buscador."
Esa versión la puedes recitar de memoria. Las mejoras siempre al final.
El truco final · describe el viaje del dato
Memoriza el flujo. Si te lo preguntan, lo recitas con seguridad y has demostrado en 20 segundos que entiendes de extremo a extremo qué pasa cuando alguien usa tu aplicación.
- Léete esta sección entera en voz alta una vez.
- Practica el bloque 1 y la versión ultra-corta hasta que salgan sin pensar.
- Repasa los 3 comodines y el vocabulario que suma.
- Si te bloqueas, tira del "viaje del dato": te saca de cualquier laguna porque siempre puedes contar el flujo.
16 · Mis otros proyectos personales
Obsidian · e-commerce full-stack de streetwear
Tienda React con catálogo servido por una API Laravel 11, autenticación con sesiones-cookie, carrito y wishlist sincronizados entre invitado y usuario, y un checkout básico que crea pedidos reales. El frontend está desplegado en Cloudflare y la API+MySQL en Railway.
Para qué sirve cada una
- React 19 · librería UI por componentes. Renderiza listados de producto, modales del carrito, drawer, dashboard de cuenta.
- TypeScript · tipado estático. Modela
Product,CartItem,Categoryy blinda los contratos con la API. - Vite 8 · servidor de desarrollo con HMR (recarga en caliente) y bundler para producción.
- React Router 7 · navegación cliente sin recargar la página (
/shop,/product/:slug,/account...). - TanStack Query 5 · gestión de estado del servidor: cachea productos, reintenta peticiones fallidas, muestra estados de carga/error y refresca datos sin que tengas que escribirlo a mano.
- React Context · estado local de UI (carrito en memoria, wishlist guest, toasts). Complementa a Query: Query para datos del servidor, Context para datos del cliente.
- CSS plano + tokens · estilos sin framework (variables CSS para colores/espaciados), para demostrar fundamentos sin depender de Tailwind/Bootstrap.
- Laravel 11 · framework PHP del backend. Expone la API REST (
/api/products,/api/cart,/api/auth/login...). - Sanctum · sistema de autenticación de Laravel basado en cookies SPA + tokens. Resuelve login/register/logout.
- MySQL 8 · base de datos relacional donde viven productos, usuarios, pedidos, direcciones, carritos y wishlists.
- Cloudflare Workers + Assets · hosting del frontend SPA en el edge global, con fallback SPA configurado.
- Railway · hosting gestionado del backend Laravel + MySQL de producción.
useEffect + fetch + useState a mano, te da caché compartida entre componentes,
deduplicación de peticiones, retries automáticos, estados de carga/error y refetch al volver a la pestaña.
Aquí la uso para listar productos, sincronizar el carrito y la wishlist con el backend.
Lord of the Clicks · clicker incremental en la Tierra Media
Juego incremental con 30 zonas, combate por clic, semi-jefes y jefes con temporizador, árbol de mejoras, 20 compañeros reclutables y guardado persistente. La lógica del juego vive como TypeScript puro fuera de React, y React solo representa el estado. Tiene tests unitarios con Vitest y CI en GitHub Actions.
Para qué sirve cada una
- Vite 6 · build tool con HMR ultra rápido y bundle de producción optimizado.
- React 19 · capa de UI: paneles de combate, mapa interactivo, modales, drawers.
- TypeScript strict · modela el dominio del juego (
Enemy,Quest,Companion,UpgradeDefinition). El compilador exige tratar todos los casos. - Zustand 5 · estado global del cliente. Es como Redux pero sin boilerplate. Aquí guarda oro, mithril, nivel, inventario, misiones y compañeros, con selectors granulares para evitar re-renders.
- Tailwind 4 · framework CSS de utilidades. Para layouts y espaciado rápido (clases como
flex gap-4). - CSS Modules · estilos aislados por componente para piezas custom (escena de combate, mapa, retratos, currency bar) que con utilidades sería ilegible.
- Vitest 3 + Testing Library · framework de tests muy rápido sobre Vite. Cubre 43 tests: fórmulas de combate, progresión, store de Zustand y game loop.
- ESLint + jsx-a11y · linter con reglas de accesibilidad que bloquean el lint si metes un
<div onClick>en vez de<button>. - Husky + lint-staged · hooks de Git que ejecutan lint y formato automáticamente antes de cada commit, solo sobre los archivos modificados.
- GitHub Actions · pipeline de CI/CD: en cada push corre lint, typecheck, tests y build.
- Cloudflare Pages · hosting estático global con preview por PR.
- pnpm 11 · gestor de paquetes alternativo a npm; comparte dependencias entre proyectos y es mucho más rápido instalando.
Solar Explorer · Sistema Solar 3D interactivo
Escena 3D en WebGL del Sistema Solar con planetas seleccionables, tour guiado con cámara animada, panel de datos por planeta, control de velocidad temporal y soporte ES/EN. Pensado para crecer con el tiempo, no como demo de una sola página.
Para qué sirve cada una
- React 19 · toda la capa DOM: header, drawer móvil, panel de info, modales, tour guiado.
- TypeScript · modela los datos tipados de planetas y lunas, y el contenido traducible ES/EN.
- Vite · servidor de desarrollo y bundle.
- Three.js · librería 3D que se encarga del WebGL "a pelo": escenas, cámaras, materiales, geometrías, luces. Es la base de cualquier 3D en navegador.
- React Three Fiber · renderer de Three.js dentro de React. En vez de escribir Three.js imperativo (
scene.add(mesh)), declaras la escena como JSX (<mesh><sphereGeometry/></mesh>). Esto la hace componible y reactiva al estado de React. - @react-three/drei · colección de "helpers" para R3F: cámaras prediseñadas, controles de órbita, helpers de carga de modelos GLB, stars, environment, etc. Te ahorra reimplementar piezas comunes.
- Tailwind CSS · estilos del DOM (paneles, drawer, modales). El Canvas WebGL no lo estiliza Tailwind, ese vive aparte.
- Cloudflare + Wrangler · CLI para desplegar el SPA en Cloudflare Workers/Pages.
CashDrop · juego web tipo concurso
Recreación del concurso Cash Drop: 1.000.000 € en 20 fajos, 5 categorías × 3 niveles × 125 preguntas,
drag & drop táctil propio, animaciones de confeti/lluvia con <canvas>, todo sin frameworks ni build step.
El repo es directamente desplegable.
Para qué sirve cada una
- HTML5 semántico · estructura del juego (pantallas, modales, mesa central, opciones).
- CSS3 moderno · estilos. Aprovecha
grid,clamp()(tamaños fluidos),color-mix(),backdrop-filter, gradientes y máscaras para no depender de un framework de UI completo. - JavaScript vanilla · motor del juego: control de fajos, validación de apuestas, lógica de rondas, sin
npm installni transpilación. - Bootstrap 5.3 desde CDN · utilidades de layout (grid, spacing). En la versión 5 ya no depende de jQuery, va con JS propio.
- Bootstrap Icons · pack de iconos por clase CSS (
bi-globe-americas). - Google Fonts · tipografías web (Poppins para texto, Russo One para títulos).
- Sin build step · decisión consciente: el repositorio que ves es el código que se sirve en producción. Cero pipeline, cero dependencias en runtime.
FamilyTrivia · trivia familiar por equipos
Juego tipo tablero para reuniones familiares. Tiene ruletas para formar equipos equilibrados, 6 categorías × 6 niveles de puntuación, comodines, preguntas con audio (bandas sonoras), y un ranking final con gráfico de evolución de puntos. Sin frameworks, sin build.
Para qué sirve cada una
- HTML5 · dos páginas:
index.htmlcon el tablero yruletas.htmlpara formar equipos. - CSS3 · estilos responsive para escritorio, tablet y móvil, animaciones (entrada escalonada de opciones, pulso del marcador, confeti).
- JavaScript vanilla · lógica del tablero, ruletas, comodines, puntuaciones, estadísticas finales.
- Bootstrap CDN · utilidades de layout (grid, navbar, modales).
- Bootstrap Icons · iconografía consistente.
- Chart.js · librería de gráficos. La uso para pintar la evolución de puntos por equipo al final de la partida.
- Google Fonts · tipografías personalizadas.
- Pointer Events API · para arrastrar la barra del reproductor de audio funcionando igual con ratón y con dedo. La uso con
touch-action: nonepara que el navegador no haga scroll mientras arrastras.
<canvas>. Alternativas que conozco: Recharts (mejor con React), D3 (potente pero verbose),
ApexCharts y ECharts.
Portfolio 3D · aleixaj.com (este mismo portfolio)
Mi portfolio público. Escena 3D interactiva en el Hero con un modelo GLB, contenido trilingüe ES/EN/CAT,
SEO multiidioma, accesibilidad cuidada (focus trap, aria-current, navegación por teclado), animaciones reveal,
galería de arte con modal, formulario de contacto y deploy en Cloudflare. Todo está optimizado para que cargue rápido
a pesar de tener WebGL.
Para qué sirve cada una
- React 19 · UI completa: navbar, hero, secciones de trayectoria, proyectos, tecnologías, arte y contacto.
- Vite 8 · dev server con HMR y bundle de producción con code splitting manual (React, Three.js y EmailJS en chunks separados para mejor caché).
- Tailwind CSS 3 · estilos y responsive con breakpoints custom (
2xldesde 2200px,3xldesde 2560px ylspara landscape móvil). - Three.js · librería 3D que renderiza la escena del Hero (el "gaming room" GLB).
- React Three Fiber · escribir esa escena 3D como JSX de React, sincronizada con el estado de la app.
- @react-three/drei · helpers para R3F:
OrbitControls(rotar el modelo),useGLTF(carga del modelo),PerformanceMonitor(baja el DPR automáticamente si caen los FPS),Stars(fondo estrellado). - React Icons · biblioteca con los iconos de marca (React, Tailwind, GitHub, LinkedIn, Godot, Aseprite...) consumidos por componente.
- EmailJS · envío del formulario de contacto sin necesidad de backend. Las credenciales viven en variables
VITE_*. - Sharp · usada solo en build (
scripts/optimize-images.mjs) para convertir PNG → WebP y generar miniaturas de la galería de arte. - gltf-transform · CLI usado offline para comprimir el modelo 3D con meshopt y convertir texturas a WebP (reduce el GLB en un ~85%).
- Cloudflare Workers + Assets · hosting global con fallback SPA configurado.
- i18n custom · todo el contenido vive en
src/consts/(i18n, nav, projects, experience, skills) como una sola fuente de verdad trilingüe. Sin librería externa.
- Lazy loading con
React.lazypara la escena 3D y el fondo de estrellas: el bundle inicial es pequeño y Three.js solo carga cuando llega su turno. - DPR adaptativo:
PerformanceMonitorbaja la resolución del canvas si el FPS cae por debajo de ~45, y la sube cuando vuelve a ser estable. - Scroll lock controlado:
htmlybodyconoverflow: hiddeny el contenido vive en un contenedorfixedcon su propio scroll. Soluciona el "salto" típico en móvil cuando se oculta la barra del navegador (el cambio de100dvh). - SEO multiidioma:
hreflangES/EN/CA enindex.htmly sincronización en runtime detitle,description,og:*ytwitter:*al cambiar de idioma. - Accesibilidad:
focus-visibleglobal,focus trapen el menú móvil,aria-current="page"en la sección activa, banderas SVG inline para que se vean igual en cualquier sistema.
Resumen rápido para la entrevista
Si tienes 10 segundos para describir cada proyecto:
| Proyecto | En una frase | Tecnología principal a destacar |
|---|---|---|
| Obsidian | E-commerce full-stack con React + Laravel | API REST, auth con sesiones, TanStack Query |
| Lord of the Clicks | Clicker incremental con motor desacoplado de UI | Zustand, TypeScript estricto, Vitest, CI |
| Solar Explorer | Sistema Solar 3D interactivo en navegador | Three.js + React Three Fiber, WebGL |
| CashDrop | Concurso TV con drag & drop táctil propio | JS vanilla, sin build, <canvas> animado |
| FamilyTrivia | Trivia familiar por equipos con ruletas y audio | JS vanilla, Chart.js, Pointer Events API |
| Portfolio 3D | Mi portfolio web con escena 3D y trilingüe | React + R3F, code splitting, i18n ES/EN/CAT, SEO sincronizado |
<canvas>.
Recuerda que el Portfolio 3D es el que muy probablemente ya habrán visto, así que tener
claro su stack te da puntos de inmediato.