Guía paso a paso de MovieVault: Una API de lista de seguimiento con Express 5, Prisma 7 y JWT
MovieVault, un ejercicio completo de desarrollo full-stack con plazos definidos: una API de lista de seguimiento en Express 5, Prisma 7 y JWT, con notas de revisión sobre verificaciones de propiedad, efectos en cadena y manejo de errores.
Fuentes: PRISMA SCHEMA SIMPLE TOUCH (BACKEND)
Los ejercicios de codificación en vivo con tiempo limitado son una forma común de evaluar a los desarrolladores full-stack, y recompensan a aquellos candidatos que pueden transformar una especificación breve en una API funcional y segura rápidamente. Esta guía utiliza una de estas especificaciones breves: un catálogo de películas llamado MovieVault con una lista de reproducción personal, y la combina con un backend completo basado en Express, TypeScript y Prisma. Verá cómo el modelo de datos expresa una relación muchos-a-muchos, cómo las reglas de autenticación y propiedad se traducen en verificaciones en el middleware y los controladores, y qué aspectos problemáticos en una solución con plazo limitado merecen ser corregidos antes de que se utilice en entornos de producción.
La especificación de MovieVault
La aplicación permite a los usuarios navegar por un catálogo de películas, guardar títulos en una lista de reproducción personal y gestionar ambos. Las funcionalidades requeridas son:
- Registro e inicio de sesión.
El servidor utiliza Express, TypeScript y Prisma; el cliente emplea React, Tailwind y ShadCN UI.
Reglas básicas
- El tiempo asignado es de 280 minutos.
- El trabajo debe ser original del usuario.
node_modulesdebe excluirse mediante.gitignore, y se debe comprometer un archivo.env.example.- ShadCN UI es obligatorio para los componentes básicos como Button, Card e Input.
- Los errores deben manejarse en el servidor y mostrarse al usuario en el cliente, como mínimo a través de
console.logo una notificación emergente.
Configuración del servidor y del modelo de datos
El servidor es un proyecto Express escrito en TypeScript con Prisma como ORM. La solicitud indica la necesidad de tres modelos: User (un id que puede ser un número entero o una cadena, un email único, password y name), Movie (id, title, genre, description, imageUrl) y un modelo de lista de reproducción (id, userId, movieId, una cadena review cuyo valor por defecto es una cadena vacía y un número entero rating cuyo valor por defecto es 1). Los usuarios y las películas se relacionan de forma muchos-a-muchos a través de la tabla de lista de reproducción, y se deben cargar al menos cinco películas desde un archivo JSON escrito a mano en server/data/movies.json.
El esquema a continuación implementa esto como un modelo de unión explícito. Dos detalles merecen atención. La restricción @@unique([userId, movieId]) implica que un usuario no puede guardar la misma película dos veces, y también proporciona a Prisma una clave compuesta llamada userId_movieId que los controladores utilizan posteriormente para búsquedas. El bloque datasource no tiene campo url: en Prisma 7, la cadena de conexión se configura fuera del esquema (en prisma.config.ts) y el cliente se conecta a través de un adaptador de controlador, por lo que @prisma/adapter-pg y pg aparecen en las dependencias. Para conocer los pasos completos de configuración, consulte configurar Prisma 7 con PostgreSQL en un proyecto TypeScript Node.js.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String
watchList WatchList[]
}
model Movie {
id Int @id @default(autoincrement())
title String
genre String
description String
imageUrl String
watchList WatchList[]
}
model WatchList{
id Int @id @default(autoincrement())
review String @default("")
rating Int @default(1)
userId Int
movieId Int
user User @relation(fields: [userId], references: [id] )
movie Movie @relation(fields: [movieId], references: [id] )
@@unique([userId, movieId])
}
Configuración del cliente
La interfaz frontal es una aplicación React estructurada con Vite, estilizada con Tailwind CSS y ShadCN UI, y gestionada mediante React Router. La especificación relaciona cada hito del servidor con una pantalla del cliente:
- Las páginas
/loginy/registerse construyen con componentes de formulario de ShadCN. Tras un inicio de sesión exitoso, el token se almacena y el usuario es redirigido al panel de control en/. - Un panel de control que muestra las películas en forma de cuadrícula con tarjetas de ShadCN. Cada tarjeta cuenta con un botón “Añadir a la lista de reproducción” y uno “Eliminar película”, y la eliminación debe actualizar la cuadrícula de inmediato.
- Una página
/my-watchlistque enumera las películas guardadas por el usuario, junto con larevisióny lacalificaciónde cada entrada. - Un flujo de edición para las entradas de la lista de reproducción, ya sea mediante un diálogo de ShadCN o una página separada, con la interfaz actualizándose en cuanto la modificación tenga éxito.
El contrato de la API
POST /registerregistra a un usuario; no se requiere autenticación.POST /logindevuelve un token de acceso JWT; no se requiere autenticación.GET /moviesmuestra todas las películas; se requiere autenticación.DELETE /movies/:idelimina una película del catálogo; se requiere autenticación.GET /watchlistmuestra las entradas del usuario actual, utilizandoincludede Prisma para incluir detalles de la película; se requiere autenticación.POST /watchlist/:movieIdagrega una película a la lista de reproducción del usuario actual; se requiere autenticación.PATCH /watchlist/:idactualiza la reseña y la calificación sin verificar al propietario; se requiere autenticación.DELETE /watchlist/:idelimina una entrada; se requiere autenticación y el acceso está restringido al propietario.
Las contraseñas deben ser hasheadas con bcrypt.
Registro e inicio de sesión
El controlador de autenticación maneja ambos puntos finales públicos. register rechaza una solicitud con un código 400 cuando falta algún campo, hashea la contraseña con una sal de bcrypt de complejidad 12, crea al usuario y firma inmediatamente un JWT que contiene el id, name y email del usuario, el cual vence después de un día. login busca al usuario por correo electrónico, compara la contraseña con bcrypt.compare y devuelve el mismo tipo de token. Ambos controladores solo devuelven campos seguros del usuario, nunca el hash.
import { Request, Response } from "express";
import prisma from "../libs/prisma";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
export const register = async (req: Request, res: Response) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({
message: "Semua kolom harus diisi",
});
}
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
const newUser = await prisma.user.create({
data: {
name: name,
email: email,
password: hashedPassword,
},
});
const token = jwt.sign(
{
id: newUser.id,
name: newUser.name,
email: newUser.email,
},
process.env.JWT_SECRET as string,
{ expiresIn: "1d" },
);
return res.status(201).json({
message: "User Berhasil di buat",
token: token,
data: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
},
});
} catch (error) {
return res.status(500).json({
message: "gagal membuat user",
error: error,
});
}
};
export const login = async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
const user = await prisma.user.findUnique({
where: { email: email },
});
if (!user) {
return res.status(400).json({
message: "Email/Username/Password salah",
});
}
const isPasswordValid = user
? await bcrypt.compare(password, user.password)
: false;
if (!isPasswordValid) {
return res.status(400).json({
message: "Email/Username/Password salah",
});
}
const token = jwt.sign(
{
id: user.id,
name: user.name,
email: user.email,
},
process.env.JWT_SECRET as string,
{ expiresIn: "1d" },
);
return res.status(200).json({
message: "Login Berhasil",
token: token,
data: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
return res.status(500).json({
message: "Terjadi kesalahan pada server",
error: error,
});
}
};
Aspectos que merecen mejorarse:
- El inicio de sesión utiliza un mismo mensaje tanto para correos electrónicos desconocidos como para contraseñas incorrectas, lo que impide conocer qué cuentas existen. Devolver un código 401 en lugar de 400 describiría el fallo con mayor precisión.
error sin procesar al cliente. Se registra en el servidor y solo se devuelve un mensaje.process.env.JWT_SECRET como cadena de texto silencia al compilador pero no en tiempo de ejecución; si la variable falta, el proceso de firma falla en cada solicitud. Se deben validar las variables de entorno necesarias al iniciar el sistema.Para una versión más segura de este flujo, consulte la autenticación JWT lista para producción en APIs de Node.js.
El middleware de autenticación
El middleware lee el encabezado Authorization, toma la parte después del primer espacio como token y responde con 401 cuando este está ausente. Luego verifica el token con la clave compartida y adjunta la carga útil decodificada a la solicitud para que los controladores puedan leer el id del usuario. Un token inválido o vencido también genera una respuesta 401.
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
export const authentication = (
req: Request,
res: Response,
next: NextFunction,
) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ message: "No token provided" });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET as string);
(req as any).user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: "Invalid token" });
}
};
El cast (req as any).user funciona, pero descarta la verificación de tipos en todos los controladores que lo utilizan. Aumentar la interfaz Request de Express mediante la fusión de declaraciones mantiene a req.user con tipo definido. El análisis de encabezados también acepta cualquier esquema, no solo Bearer.
Dependencias del proyecto
El package.json fija la versión de los componentes en el momento de su creación: Express 5, Prisma 7 con el adaptador PostgreSQL, bcrypt, jsonwebtoken y cors, además de TypeScript y ts-node-dev para un script dev que reinicia al detectar cambios. Las gamas de versiones cambian con rapidez, por lo que debe verificar las versiones actuales antes de reutilizarlas.
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.22.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.0.1",
"prisma": "^7.8.0",
"ts-node-dev": "^2.0.0",
"typescript": "^6.0.3"
}
}
Tenga en cuenta que nada en la lista carga un archivo .env en process.env; por lo tanto, necesita herramientas como dotenv o la funcionalidad de archivos de entorno de su entorno de ejecución para que JWT_SECRET esté disponible.
Endpoints de películas
getMovies simplemente devuelve todas las películas. deleteMovie analiza el parámetro de la ruta, devuelve 400 si el ID no es numérico y 404 si es desconocido, y luego ejecuta una $transaction que primero elimina todas las filas de la lista de reproducción que hacen referencia a la película y luego a la propia película. Eliminar primero los elementos hijos evita una violación de clave foránea, y la transacción asegura que ambos pasos tengan éxito o fracasen juntos.
import { Request, Response } from "express";
import prisma from "../libs/prisma";
// GET /movies - Mendapatkan semua daftar film
export const getMovies = async (req: Request, res: Response) => {
try {
const movies = await prisma.movie.findMany();
return res.status(200).json(movies);
} catch (error) {
console.error("Get Movies Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat mengambil data film",
});
}
};
// DELETE /movies/:id - Menghapus film dari katalog utama
export const deleteMovie = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const movieId = parseInt(id, 10);
if (isNaN(movieId)) {
return res.status(400).json({ message: "ID film tidak valid" });
}
// Periksa apakah film ada
const movie = await prisma.movie.findUnique({
where: { id: movieId },
});
if (!movie) {
return res.status(404).json({ message: "Film tidak ditemukan" });
}
// Menggunakan Transaction untuk menghapus watchlist terkait terlebih dahulu
// guna menghindari pelanggaran batasan foreign key (kunci asing)
await prisma.$transaction([
prisma.watchList.deleteMany({
where: { movieId: movieId },
}),
prisma.movie.delete({
where: { id: movieId },
}),
]);
return res.status(200).json({
message: "Film berhasil dihapus dari katalog utama",
});
} catch (error) {
console.error("Delete Movie Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menghapus film",
});
}
};
Otra alternativa es declarar onDelete: Cascade en la relación movie de la lista de reproducción, permitiendo que la base de datos elimine automáticamente las filas dependientes. La transacción explícita es más visible en el código; el mecanismo de cascada es más difícil de olvidar.
Puntos finales de la lista de reproducción
El controlador de la lista de reproducción contiene las reglas más interesantes:
getWatchlistfiltra según el ID de usuario del token y utilizainclude: { movie: true }para que cada entrada llegue junto con su película.addToWatchlistvalida el ID de la película, verifica que exista, busca la clave compuestauserId_movieIdpara rechazar duplicados con un código 400, y crea la entrada con la reseña y calificación predeterminadas.updateWatchlistaplica la regla colaborativa: comprueba que la entrada exista pero no quién es su propietario. Si está presente el camporating, este debe ser interpretado como un número entero del 1 al 5. Prisma ignora los campos dejados enundefined, por lo que una actualización parcial afecta únicamente a los datos enviados.deleteFromWatchlistcarga la entrada y devuelve un código 403 cuando suuserIddifiere del del usuario que realiza la solicitud, lo cual aplica la regla de acceso exclusivo para los propietarios.
import { Request, Response } from "express";
import prisma from "../libs/prisma";
// GET /watchlist - Mengambil semua watchlist milik user yang sedang login
export const getWatchlist = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const watchlist = await prisma.watchList.findMany({
where: { userId: userId },
include: {
movie: true, // Menyertakan informasi film
},
});
return res.status(200).json(watchlist);
} catch (error) {
console.error("Get Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat mengambil watchlist",
});
}
};
// POST /watchlist/:movieId - Menambahkan film ke watchlist user yang login
export const addToWatchlist = async (req: Request, res: Response) => {
try {
const { movieId } = req.params;
const userId = (req as any).user.id;
const parsedMovieId = parseInt(movieId, 10);
if (isNaN(parsedMovieId)) {
return res.status(400).json({ message: "ID film tidak valid" });
}
// Periksa apakah film tersebut ada di katalog
const movie = await prisma.movie.findUnique({
where: { id: parsedMovieId },
});
if (!movie) {
return res.status(404).json({ message: "Film tidak ditemukan" });
}
// Periksa apakah film sudah ada di watchlist user tersebut
const existingWatchlist = await prisma.watchList.findUnique({
where: {
userId_movieId: {
userId: userId,
movieId: parsedMovieId,
},
},
});
if (existingWatchlist) {
return res.status(400).json({
message: "Film ini sudah ada di dalam daftar watchlist Anda",
});
}
const newWatchlistItem = await prisma.watchList.create({
data: {
userId: userId,
movieId: parsedMovieId,
review: "",
rating: 1,
},
include: {
movie: true,
},
});
return res.status(201).json({
message: "Film berhasil ditambahkan ke watchlist",
data: newWatchlistItem,
});
} catch (error) {
console.error("Add to Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menambahkan watchlist",
});
}
};
// PATCH /watchlist/:id - Memperbarui review dan rating (siapa saja yang login bisa mengubah)
export const updateWatchlist = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { review, rating } = req.body;
const watchlistId = parseInt(id, 10);
if (isNaN(watchlistId)) {
return res.status(400).json({ message: "ID watchlist tidak valid" });
}
// Periksa apakah item watchlist ada
const watchlistItem = await prisma.watchList.findUnique({
where: { id: watchlistId },
});
if (!watchlistItem) {
return res.status(404).json({ message: "Item watchlist tidak ditemukan" });
}
// Validasi rating jika dikirimkan
if (rating !== undefined) {
const parsedRating = parseInt(rating, 10);
if (isNaN(parsedRating) || parsedRating < 1 || parsedRating > 5) {
return res.status(400).json({
message: "Rating harus berupa angka antara 1 sampai 5",
});
}
}
const updatedItem = await prisma.watchList.update({
where: { id: watchlistId },
data: {
review: review !== undefined ? review : undefined,
rating: rating !== undefined ? parseInt(rating, 10) : undefined,
},
include: {
movie: true,
},
});
return res.status(200).json({
message: "Watchlist berhasil diperbarui",
data: updatedItem,
});
} catch (error) {
console.error("Update Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat memperbarui watchlist",
});
}
};
// DELETE /watchlist/:id - Menghapus film dari watchlist (hanya bisa oleh pembuatnya/Owner)
export const deleteFromWatchlist = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = (req as any).user.id;
const watchlistId = parseInt(id, 10);
if (isNaN(watchlistId)) {
return res.status(400).json({ message: "ID watchlist tidak valid" });
}
// Periksa apakah item watchlist ada
const watchlistItem = await prisma.watchList.findUnique({
where: { id: watchlistId },
});
if (!watchlistItem) {
return res.status(404).json({ message: "Item watchlist tidak ditemukan" });
}
// Validasi kepemilikan (Owner)
if (watchlistItem.userId !== userId) {
return res.status(403).json({
message: "Anda tidak memiliki akses untuk menghapus item ini dari watchlist",
});
}
await prisma.watchList.delete({
where: { id: watchlistId },
});
return res.status(200).json({
message: "Film berhasil dihapus dari watchlist Anda",
});
} catch (error) {
console.error("Delete Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menghapus watchlist",
});
}
};
Hay dos casos límite que deben tenerse en cuenta. La verificación de duplicados y la inserción son consultas separadas, por lo que dos solicitudes simultáneas pueden superar la verificación; la restricción de unicidad sigue protegiendo los datos, pero quien pierda recibe un error 500 a menos que se detecte la violación. Además, parseInt es indulgente: "4.7" se convierte en 4 y "3abc" en 3, por lo que una verificación más estricta como Number.isInteger(Number(rating)) rechaza los datos mal formados de manera más fiable.
Conexión de las rutas
La capa de enrutamiento cuenta con un archivo por recurso además de un índice. Las rutas de autenticación se montan bajo /auth, mientras que /movies y /watchlist pasan por el middleware de authentication en el momento del montaje, de modo que cada manejador detrás de ellos está protegido sin necesidad de repetir la verificación. El punto de entrada habilita CORS, analiza los cuerpos JSON, monta todo bajo /api y escucha en el puerto 3000.
authRoute.ts
import { Router } from "express";
import { login, register } from "../controllers/authController";
const router = Router();
router.post("/register", register);
router.post("/login", login);
export default router;
index.ts
import { Router } from "express";
import authRoute from "./authRoute";
import movieRoute from "./movieRoute";
import watchlistRoute from "./watchlistRoute";
import { authentication } from "../middlewares/authMiddleware";
const route = Router();
route.use("/auth", authRoute);
route.use("/movies", authentication, movieRoute);
route.use("/watchlist", authentication, watchlistRoute);
export default route;
movieRoute.ts
import { Router } from "express";
import { deleteMovie, getMovies } from "../controllers/movieController";
const router = Router();
router.get("/", getMovies);
router.delete("/:id", deleteMovie);
export default router;
watchlistRoute.ts
import { Router } from "express";
import {
getWatchlist,
addToWatchlist,
updateWatchlist,
deleteFromWatchlist,
} from "../controllers/watchlistController";
const router = Router();
router.get("/", getWatchlist);
router.post("/:movieId", addToWatchlist);
router.patch("/:id", updateWatchlist);
router.delete("/:id", deleteFromWatchlist);
export default router;
isi main index.ts yang selevel dengan src:
import express from "express";
import route from "./routes";
import cors from "cors";
const app = express();
const port = 3000;
app.use(cors());
app.use(express.json());
app.use("/api", route);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
Debido a esos prefijos, las rutas reales son /api/auth/register, /api/movies y así sucesivamente, no las rutas simples indicadas en el contrato. Asegúrese de que el cliente y cualquier documentación de la API utilicen la misma URL base.
Puntos clave
- Un modelo de unión explícito con una restricción única compuesta le brinda tanto protección contra duplicados como una clave de búsqueda conveniente.
req.user y validar las variables de entorno al iniciar el programa.