Programación Web · Capítulo 20

Proyecto Fullstack: De la Idea al Producto Final

Integra todo lo aprendido construyendo un gestor de tareas completo — con autenticación, base de datos, API REST y frontend en React.


1. Planificación del Proyecto

Antes de escribir una sola línea de código, debes entender qué estás construyendo. Un proyecto mal planificado genera código que hay que reescribir. Invertir tiempo en planificación ahorra semanas de trabajo.

Requisitos del Proyecto: TaskFlow

TaskFlow — gestor de tareas personal con autenticación.

Funcionalidades: registro e inicio de sesión de usuarios, crear/leer/actualizar/eliminar tareas, marcar tareas como completadas, filtrar por estado (pendiente/completada), interfaz responsiva.

Decisiones de Stack Tecnológico

CapaTecnologíaRazón
FrontendReact + ViteSPA moderna, componentes reutilizables
EstilosTailwind CSSUtilidades, sin CSS propio que mantener
BackendNode.js + ExpressJavaScript en ambos lados, simple
Base de datosPostgreSQLRelacional, gratuito en Render
AutenticaciónJWT + bcryptSin estado, estándar de la industria
Hosting FrontendVercelGratis, CI/CD automático
Hosting BackendRenderGratis, soporta Node.js y PostgreSQL

2. Diseño de la Base de Datos

-- Schema de la base de datos CREATE TABLE usuarios ( id SERIAL PRIMARY KEY, nombre VARCHAR(100) NOT NULL, email VARCHAR(150) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, creado_en TIMESTAMP DEFAULT NOW() ); CREATE TABLE tareas ( id SERIAL PRIMARY KEY, usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, titulo VARCHAR(300) NOT NULL, descripcion TEXT, completada BOOLEAN DEFAULT FALSE, prioridad VARCHAR(10) DEFAULT 'media' CHECK (prioridad IN ('alta','media','baja')), fecha_limite DATE, creado_en TIMESTAMP DEFAULT NOW(), actualizado_en TIMESTAMP DEFAULT NOW() ); -- Índice para consultas frecuentes por usuario CREATE INDEX idx_tareas_usuario ON tareas(usuario_id); CREATE INDEX idx_tareas_completada ON tareas(usuario_id, completada);

3. Estructura del Proyecto

taskflow/ ├── backend/ │ ├── src/ │ │ ├── config/ │ │ │ └── db.js # Conexión a PostgreSQL │ │ ├── middleware/ │ │ │ ├── auth.js # Verificar JWT │ │ │ └── validar.js # express-validator │ │ ├── routes/ │ │ │ ├── auth.js # /api/auth/register, /api/auth/login │ │ │ └── tareas.js # /api/tareas CRUD │ │ └── controllers/ │ │ ├── authController.js │ │ └── tareasController.js │ ├── .env │ ├── index.js │ └── package.json │ └── frontend/ ├── src/ │ ├── components/ │ │ ├── TarjetaTarea.jsx │ │ ├── FormularioTarea.jsx │ │ └── Navbar.jsx │ ├── pages/ │ │ ├── Login.jsx │ │ ├── Registro.jsx │ │ └── Dashboard.jsx │ ├── context/ │ │ └── AuthContext.jsx │ ├── hooks/ │ │ └── useTareas.js │ ├── services/ │ │ └── api.js # Configuración de axios │ └── App.jsx └── package.json

4. Backend: Autenticación

// backend/src/controllers/authController.js const bcrypt = require("bcrypt"); const jwt = require("jsonwebtoken"); const pool = require("../config/db"); exports.registrar = async (req, res) => { try { const { nombre, email, password } = req.body; // Verificar si el email ya existe const existe = await pool.query( "SELECT id FROM usuarios WHERE email = $1", [email] ); if (existe.rows.length > 0) { return res.status(409).json({ error: "El email ya está registrado" }); } // Hashear la contraseña const hash = await bcrypt.hash(password, 12); // Insertar usuario const { rows } = await pool.query( "INSERT INTO usuarios (nombre, email, password_hash) VALUES ($1, $2, $3) RETURNING id, nombre, email", [nombre, email, hash] ); // Generar token JWT const token = jwt.sign( { id: rows[0].id, email: rows[0].email }, process.env.JWT_SECRET, { expiresIn: "7d" } ); res.status(201).json({ token, usuario: rows[0] }); } catch (error) { console.error(error); res.status(500).json({ error: "Error interno del servidor" }); } }; exports.login = async (req, res) => { try { const { email, password } = req.body; const { rows } = await pool.query( "SELECT * FROM usuarios WHERE email = $1", [email] ); if (rows.length === 0) { return res.status(401).json({ error: "Credenciales inválidas" }); } const coincide = await bcrypt.compare(password, rows[0].password_hash); if (!coincide) { return res.status(401).json({ error: "Credenciales inválidas" }); } const token = jwt.sign( { id: rows[0].id, email: rows[0].email }, process.env.JWT_SECRET, { expiresIn: "7d" } ); res.json({ token, usuario: { id: rows[0].id, nombre: rows[0].nombre, email: rows[0].email } }); } catch (error) { res.status(500).json({ error: "Error interno del servidor" }); } };

5. Backend: CRUD de Tareas

// backend/src/controllers/tareasController.js const pool = require("../config/db"); exports.listar = async (req, res) => { const { completada } = req.query; let query = "SELECT * FROM tareas WHERE usuario_id = $1"; const params = [req.usuario.id]; if (completada !== undefined) { query += " AND completada = $2"; params.push(completada === "true"); } query += " ORDER BY creado_en DESC"; const { rows } = await pool.query(query, params); res.json(rows); }; exports.crear = async (req, res) => { const { titulo, descripcion, prioridad, fecha_limite } = req.body; const { rows } = await pool.query( "INSERT INTO tareas (usuario_id, titulo, descripcion, prioridad, fecha_limite) VALUES ($1,$2,$3,$4,$5) RETURNING *", [req.usuario.id, titulo, descripcion, prioridad || "media", fecha_limite] ); res.status(201).json(rows[0]); }; exports.actualizar = async (req, res) => { const { id } = req.params; const { titulo, completada, prioridad } = req.body; // Verificar que la tarea pertenece al usuario const { rows } = await pool.query( "UPDATE tareas SET titulo=$1, completada=$2, prioridad=$3, actualizado_en=NOW() WHERE id=$4 AND usuario_id=$5 RETURNING *", [titulo, completada, prioridad, id, req.usuario.id] ); if (rows.length === 0) return res.status(404).json({ error: "Tarea no encontrada" }); res.json(rows[0]); }; exports.eliminar = async (req, res) => { const { rows } = await pool.query( "DELETE FROM tareas WHERE id=$1 AND usuario_id=$2 RETURNING id", [req.params.id, req.usuario.id] ); if (rows.length === 0) return res.status(404).json({ error: "Tarea no encontrada" }); res.json({ mensaje: "Tarea eliminada" }); };

6. Frontend: Context de Autenticación

// frontend/src/context/AuthContext.jsx import { createContext, useContext, useState, useEffect } from "react"; import api from "../services/api"; const AuthContext = createContext(); export function AuthProvider({ children }) { const [usuario, setUsuario] = useState(null); const [cargando, setCargando] = useState(true); useEffect(() => { const token = localStorage.getItem("token"); if (token) { api.defaults.headers.common["Authorization"] = `Bearer ${token}`; const payload = JSON.parse(atob(token.split(".")[1])); setUsuario(payload); } setCargando(false); }, []); const login = (token, userData) => { localStorage.setItem("token", token); api.defaults.headers.common["Authorization"] = `Bearer ${token}`; setUsuario(userData); }; const logout = () => { localStorage.removeItem("token"); delete api.defaults.headers.common["Authorization"]; setUsuario(null); }; return ( <AuthContext.Provider value={{ usuario, login, logout, cargando }}> {children} </AuthContext.Provider> ); } export const useAuth = () => useContext(AuthContext);

7. Frontend: Dashboard de Tareas

// frontend/src/pages/Dashboard.jsx import { useState, useEffect } from "react"; import api from "../services/api"; export default function Dashboard() { const [tareas, setTareas] = useState([]); const [filtro, setFiltro] = useState("todas"); const [nuevaTarea, setNuevaTarea] = useState(""); useEffect(() => { cargarTareas(); }, [filtro]); async function cargarTareas() { const params = filtro !== "todas" ? `?completada=${filtro === "completadas"}` : ""; const { data } = await api.get(`/tareas${params}`); setTareas(data); } async function agregarTarea(e) { e.preventDefault(); if (!nuevaTarea.trim()) return; const { data } = await api.post("/tareas", { titulo: nuevaTarea }); setTareas([data, ...tareas]); setNuevaTarea(""); } async function toggleTarea(tarea) { const { data } = await api.put(`/tareas/${tarea.id}`, { ...tarea, completada: !tarea.completada }); setTareas(tareas.map(t => t.id === data.id ? data : t)); } async function eliminarTarea(id) { await api.delete(`/tareas/${id}`); setTareas(tareas.filter(t => t.id !== id)); } return ( <div className="max-w-2xl mx-auto p-6"> <form onSubmit={agregarTarea} className="flex gap-2 mb-6"> <input value={nuevaTarea} onChange={e => setNuevaTarea(e.target.value)} placeholder="Nueva tarea..." className="flex-1 border rounded px-4 py-2" /> <button type="submit" className="bg-teal-600 text-white px-6 py-2 rounded"> Agregar </button> </form> {/* Filtros y lista de tareas */} </div> ); }

8. Pruebas Básicas con Jest

// backend/tests/auth.test.js const request = require("supertest"); const app = require("../index"); describe("Autenticación", () => { test("Registrar usuario nuevo retorna 201 y token", async () => { const res = await request(app) .post("/api/auth/register") .send({ nombre: "Test User", email: "test@ejemplo.com", password: "Password123" }); expect(res.statusCode).toBe(201); expect(res.body).toHaveProperty("token"); expect(res.body.usuario.email).toBe("test@ejemplo.com"); }); test("Login con credenciales incorrectas retorna 401", async () => { const res = await request(app) .post("/api/auth/login") .send({ email: "test@ejemplo.com", password: "incorrecta" }); expect(res.statusCode).toBe(401); }); test("Crear tarea sin autenticación retorna 401", async () => { const res = await request(app) .post("/api/tareas") .send({ titulo: "Mi tarea" }); expect(res.statusCode).toBe(401); }); });

9. Despliegue del Proyecto Fullstack

## Pasos de despliegue: ### Backend en Render: 1. Crear cuenta en render.com 2. New > Web Service > conectar repositorio GitHub 3. Root Directory: backend/ 4. Build Command: npm install 5. Start Command: npm start 6. Añadir variables de entorno: DATABASE_URL = (URL de Render PostgreSQL) JWT_SECRET = (clave aleatoria larga) NODE_ENV = production ### Base de datos en Render: 1. New > PostgreSQL 2. Render te da la DATABASE_URL interna ### Frontend en Vercel: 1. Crear cuenta en vercel.com 2. Importar repositorio 3. Root Directory: frontend/ 4. Build Command: npm run build 5. Output Directory: dist 6. Añadir variable de entorno: VITE_API_URL = https://tu-backend.onrender.com/api ### En el frontend, usar la variable: const api = axios.create({ baseURL: import.meta.env.VITE_API_URL });

10. Próximos Pasos Como Desarrollador

Resumen del Capítulo