MovieVault Walkthrough: A Watchlist API With Express 5, Prisma 7 and JWT
MovieVault, a timed full-stack exercise: a watchlist API on Express 5, Prisma 7 and JWT, with review notes on ownership checks, cascades and error handling.
This article is published in English.
Sources: PRISMA SCHEMA SIMPLE TOUCH (BACKEND)
Timed live-coding exercises are a common way to assess full-stack developers, and they reward candidates who can turn a short spec into a working, secured API quickly. This walkthrough uses one such brief, a movie catalog called MovieVault with a personal watchlist, and pairs it with a complete Express, TypeScript and Prisma backend. You will see how the data model expresses a many-to-many relationship, how authentication and ownership rules map onto middleware and controller checks, and which rough edges in a time-boxed solution are worth fixing before it goes anywhere near production.
The MovieVault brief
The application lets users browse a catalog of films, save titles to a personal watchlist and manage both. The required features are:
- Registration and login.
- A list of available movies, with the ability to delete a movie from the main catalog.
- Adding a movie to the user's own watchlist.
- Editing the review and rating of a watchlist entry. This is deliberately collaborative: any logged-in user may edit any entry, not only the person who created it.
- Removing a movie from a watchlist, which only the entry's owner may do.
The server uses Express, TypeScript and Prisma; the client uses React, Tailwind and ShadCN UI.
Ground rules
- The time box is 280 minutes.
- Work must be your own.
node_modulesmust be excluded through.gitignore, and a.env.examplefile must be committed.- ShadCN UI is mandatory for core components such as Button, Card and Input.
- Errors must be handled on the server and surfaced to the user on the client, at minimum through
console.logor a toast.
Setting up the server and the data model
The server is an Express project written in TypeScript with Prisma as the ORM. The brief asks for three models: User (an id that may be an integer or a string, a unique email, password and name), Movie (id, title, genre, description, imageUrl) and a watchlist model (id, userId, movieId, a review string defaulting to an empty string and an integer rating defaulting to 1). Users and movies relate many-to-many through the watchlist table, and at least five movies must be seeded from a hand-written JSON file at server/data/movies.json.
The schema below implements that as an explicit join model. Two details deserve attention. The @@unique([userId, movieId]) constraint means a user cannot save the same film twice, and it also gives Prisma a compound key named userId_movieId that the controllers use later for lookups. The datasource block has no url: with Prisma 7, the connection string is configured outside the schema (in prisma.config.ts) and the client connects through a driver adapter, which is why @prisma/adapter-pg and pg appear in the dependencies. For the full configuration steps, see setting up Prisma 7 with PostgreSQL in a TypeScript Node.js project.
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])
}
Setting up the client
The front end is a React app scaffolded with Vite, styled with Tailwind CSS and ShadCN UI, and routed with React Router. The spec pairs each server milestone with a client screen:
/loginand/registerpages built from ShadCN form components. After a successful login, the token is stored and the user is redirected to the dashboard at/.- A dashboard that renders movies as a grid of ShadCN Cards. Each card has an "Add to Watchlist" and a "Delete Movie" button, and deleting must update the grid immediately.
- A
/my-watchlistpage listing the user's saved films together with each entry'sreviewandrating. - An edit flow for watchlist entries, either a ShadCN Dialog or a separate page, with the UI refreshing as soon as the update succeeds.
The API contract
POST /registerregisters a user; no authentication.POST /loginreturns a JWT access token; no authentication.GET /movieslists all movies; authenticated.DELETE /movies/:idremoves a movie from the catalog; authenticated.GET /watchlistlists the current user's entries, using Prisma'sincludeto attach movie details; authenticated.POST /watchlist/:movieIdadds a movie to the current user's watchlist; authenticated.PATCH /watchlist/:idupdates review and rating with no owner check; authenticated.DELETE /watchlist/:idremoves an entry; authenticated and restricted to the owner.
Passwords must be hashed with bcrypt.
Registration and login
The auth controller covers both public endpoints. register rejects a request with a 400 when any field is missing, hashes the password with a bcrypt salt of cost 12, creates the user, and immediately signs a JWT containing the user's id, name and email that expires after one day. login looks the user up by email, compares the password with bcrypt.compare, and returns the same kind of token. Both handlers return only safe user fields, never the 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,
});
}
};
Things worth tightening:
- Login uses one message for an unknown email and a wrong password, which avoids revealing which accounts exist. Returning 401 rather than 400 would describe the failure more precisely.
- Registering an email that already exists trips the unique constraint and ends in a generic 500. Catching Prisma's unique-violation error and answering with 409 gives the client something actionable.
- Both catch blocks send the raw
errorobject to the client. Log it on the server and return only a message. process.env.JWT_SECRET as stringsilences the compiler but not the runtime; if the variable is missing, signing fails on every request. Validate required environment variables at startup.
For a hardened version of this flow, see production-ready JWT authentication in Node.js APIs.
The authentication middleware
The middleware reads the Authorization header, takes the part after the first space as the token, and answers 401 when it is absent. It then verifies the token with the shared secret and attaches the decoded payload to the request so controllers can read the user's id. An invalid or expired token also yields 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" });
}
};
The (req as any).user cast works, but it discards type checking in every controller that reads it. Augmenting Express's Request interface through declaration merging keeps req.user typed. The header parsing also accepts any scheme, not just Bearer.
Project dependencies
The package.json pins the stack at the time of writing: Express 5, Prisma 7 with the PostgreSQL adapter, bcrypt, jsonwebtoken and cors, plus TypeScript and ts-node-dev for a restart-on-change dev script. Version ranges move quickly, so check current releases before reusing them.
{
"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"
}
}
Note that nothing in the list loads a .env file into process.env, so you need something like dotenv or your runtime's env-file support for JWT_SECRET to be available.
Movie endpoints
getMovies simply returns every movie. deleteMovie parses the route parameter, returns 400 for a non-numeric ID and 404 for an unknown one, and then runs a $transaction that first deletes all watchlist rows referencing the movie and then the movie itself. Deleting the children first avoids a foreign key violation, and the transaction makes the two steps succeed or fail together.
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",
});
}
};
An alternative is to declare onDelete: Cascade on the watchlist's movie relation, letting the database remove dependent rows automatically. The explicit transaction is more visible in code; the cascade is harder to forget.
Watchlist endpoints
The watchlist controller holds the most interesting rules:
getWatchlistfilters by the user ID from the token and usesinclude: { movie: true }so each entry arrives with its film.addToWatchlistvalidates the movie ID, confirms the movie exists, looks up the compound keyuserId_movieIdto reject duplicates with a 400, and creates the entry with the default review and rating.updateWatchlistimplements the collaborative rule: it checks that the entry exists but not who owns it. Ifratingis present, it must parse to an integer from 1 to 5. Fields leftundefinedare ignored by Prisma, so a partial update touches only what was sent.deleteFromWatchlistloads the entry and returns 403 when itsuserIddiffers from the caller's, which enforces the owner-only rule.
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",
});
}
};
Two edge cases to know about. The duplicate check and the insert are separate queries, so two simultaneous requests can both pass the check; the unique constraint still protects the data, but the loser receives a 500 unless you catch the violation. And parseInt is lenient: "4.7" becomes 4 and "3abc" becomes 3, so a stricter check such as Number.isInteger(Number(rating)) rejects malformed input more reliably.
Wiring the routes
The routing layer has one file per resource plus an index. Auth routes are mounted under /auth, while /movies and /watchlist pass through the authentication middleware at mount time, so every handler behind them is protected without repeating the check. The entry point enables CORS, parses JSON bodies, mounts everything under /api and listens on port 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}`);
});
Because of those prefixes, the real paths are /api/auth/register, /api/movies and so on, not the bare paths in the contract. Make sure the client and any API documentation use the same base URL.
Key takeaways
- An explicit join model with a compound unique constraint gives you both duplicate protection and a convenient lookup key.
- Apply authentication once at the router level, then express authorization rules, such as owner-only deletes, inside the specific controller.
- Decide deliberately how dependent rows disappear: a transaction in code or a cascade in the schema.
- In a time-boxed solution, the first fixes are usually unique-violation handling, not leaking raw errors, typing
req.userand validating environment variables at startup.