Главная / Статьи

Руководство по MovieVault: API списка для просмотра с использованием Express 5, Prisma 7 и JWT

MovieVault — задание на полный цикл разработки с временными ограничениями: API для списка фильмов в режиме ожидания на базе Express 5, Prisma 7 и JWT, с примечаниями по проверкам прав собственности, каскадным реакциям и обработке ошибок.

Издатель: REACTAPP.TOP14 мин чтения3198 слов

Источники: PRISMA SCHEMA SIMPLE TOUCH (BACKEND)

Упражнения с временными ограничениями в режиме реального времени — это распространённый способ оценки разработчиков full-stack, при котором поощряются кандидаты, способные быстро преобразовать краткое описание в рабочий и защищённый API. В этом руководстве используется подобное краткое описание — каталог фильмов под названием MovieVault с личным списком просмотра, — и он сочетается с полноценным бэкендом на базе Express, TypeScript и Prisma. Вы увидите, как модель данных отражает многократные связи, как правила аутентификации и приватности реализуются через мидлвэр и проверки контроллеров, а также какие недостатки в решении с ограниченным временем следует устранить перед тем, как оно попадёт в производственную среду.

Описание проекта MovieVault

Приложение позволяет пользователям просматривать каталог фильмов, сохранять названия в личный список просмотра и управлять ими. Требуемые функции включают:

  • Регистрация и вход.
  • Список доступных фильмов с возможностью удаления фильма из основного каталога.
  • Добавление фильма в личный список просмотра пользователя.
  • Редактирование отзыва и оценки записи в списке просмотра. Эта функция предназначена для совместной работы: любой залогиненный пользователь может редактировать любую запись, а не только тот, кто её создал.
  • Удаление фильма из списка просмотра, что может делать только владелец записи.
  • На сервере используются Express, TypeScript и Prisma; на клиенте — React, Tailwind и ShadCN UI.

    Основные правила

    • Временной лимит составляет 280 минут.
    • Работа должна быть собственным творением пользователя.
    • Папка node_modules должна быть исключена с помощью файла .gitignore, а файл .env.example должен быть добавлен к коммиту.
    • Для таких основных компонентов, как Button, Card и Input, использование ShadCN UI является обязательным.
    • Ошибки должны обрабатываться на сервере, а информация о них должна отображаться пользователю на клиентской стороне, по крайней мере с помощью console.log или сообщения в виде поп-апа.

    Настройка сервера и модели данных

    Сервер представляет собой проект на Express, написанный на TypeScript с использованием Prisma в качестве ORM. В задании требуется создать три модели: User (поле id, которое может быть целым числом или строкой, уникальный адрес email, поля password и name), Movie (id, title, genre, description, imageUrl) и модель списка для просмотра (id, userId, movieId, строка review по умолчанию пустая и целое число rating по умолчанию равное 1)). Пользователи и фильмы связаны между собой по принципу «многие к многим» через таблицу списка для просмотра, причем как минимум пять фильмов должны быть загружены из вручную созданного файла JSON по пути server/data/movies.json.

    Приведённая ниже схема реализует это в виде явной модели объединения. Следует обратить внимание на два момента. Ограничение @@unique([userId, movieId]) означает, что пользователь не может сохранить один и тот же фильм дважды, а также предоставляет Prisma композитный ключ с именем userId_movieId, который позже используется контроллерами для поиска. В блоке datasource отсутствует параметр url: в Prisma 7 строка подключения настраивается вне схемы (в файле prisma.config.ts), а клиент подключается через адаптер-драйвер, поэтому в списке зависимостей присутствуют @prisma/adapter-pg и pg. Подробные инструкции по настройке см. в настройке Prisma 7 с PostgreSQL в проекте на 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])
    }
    

    Настройка клиента

    Фронтенд представляет собой приложение на React, построенное с использованием Vite, оформленное с помощью Tailwind CSS и ShadCN UI, а навигация реализована через React Router. В спецификации каждый этап работы сервера соотносится с конкретным экраном клиента:

    • Страницы /login и /register созданы с использованием компонентов форм от ShadCN. После успешного входа токен сохраняется, и пользователь перенаправляется на панель управления по адресу /.
    • Панель управления отображает фильмы в виде сетки из карточек ShadCN. Каждая карточка содержит кнопки «Добавить в список просмотра» и «Удалить фильм», причем при удалении сетка должна мгновенно обновляться.
    • Страница /my-watchlist, на которой перечисляются сохраненные пользователем фильмы вместе с их отзывами и оценками.
    • Функция редактирования записей списка просмотра — либо с использованием диалогового окна ShadCN, либо на отдельной странице; интерфейс обновляется сразу после успешной модификации.

    Контракт API

    • POST /register — регистрация пользователя; аутентификация не требуется.
    • POST /login — возвращает токен доступа JWT; аутентификация не требуется.
    • GET /movies — выводит список всех фильмов; требуется аутентификация.
    • DELETE /movies/:id — удаляет фильм из каталога; требуется аутентификация.
    • GET /watchlist — выводит записи текущего пользователя, с использованием функции include от Prisma для получения деталей фильмов; требуется аутентификация.
    • POST /watchlist/:movieId — добавляет фильм в список просмотра текущего пользователя; требуется аутентификация.
    • PATCH /watchlist/:id — обновляет отзыв и оценку без проверки владельца; требуется аутентификация.
    • DELETE /watchlist/:id — удаляет запись; требуется аутентификация, доступ разрешен только владельцу.

    Пароли должны хешироваться с использованием bcrypt.

    Регистрация и вход

    Контроллер аутентификации обрабатывает оба публичных конца. Функция register отклоняет запрос с кодом 400, если отсутствует какое-либо поле, хэширует пароль с использованием соли bcrypt с параметром сложности 12, создаёт пользователя и немедленно генерирует токен JWT, содержащий id, name и email пользователя, срок действия которого составляет один день. Функция login находит пользователя по электронной почте, сравнивает пароль с помощью bcrypt.compare и возвращает аналогичный токен. Оба обработчика возвращают только безопасные поля пользователя, никогда не хэш.

    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,
        });
      }
    };
    

    Аспекты, которые стоит улучшить:

    • При входе с неверной электронной почтой или паролем используется одно и то же сообщение, что мешает выяснить, существуют ли такие учётные записи. Возвращение кода 401 вместо 400 позволило бы более точно описать причину сбоя.
  • Регистрация уже существующей почты приводит к нарушению ограничения уникальности и заканчивается общим кодом ошибки 500. Перехват ошибки нарушения уникальности в Prisma и ответ клиенту с кодом 409 позволяет предоставить ему полезную информацию для дальнейших действий.
  • Оба блока обработки ошибок отправляют необработанный объект error клиенту. Записывайте его на сервере и возвращайте только сообщение.
  • process.env.JWT_SECRET as string заглушает предупреждения компилятора, но не во время выполнения; если переменная отсутствует, подпись токена сбрасывается при каждом запросе. Проверяйте необходимые переменные окружения при запуске.
  • Для усиленной версии этого процесса см. механизм аутентификации JWT, готовый к использованию в API на Node.js.

    Промежуточный компонент аутентификации

    Мидлвэрь считывает заголовок Authorization, берет часть после первого пробела в качестве токена и возвращает код 401 при его отсутствии. Затем он проверяет токен с помощью общего секрета и присоединяет декодированный пейлоад к запросу, чтобы контроллеры могли прочитать id пользователя. Недействительный или истекший токен также приводит к коду 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" });
      }
    };
    

    Преобразование (req as any).user действительно работает, но оно отключает проверку типов во всех контроллерах, которые его используют. Расширение интерфейса Request в Express с помощью объединения деклараций сохраняет типизацию для req.user. Парсинг заголовков также поддерживает любые схемы, а не только Bearer.

    Зависимости проекта

    package.json фиксирует версии компонентов стека на момент написания кода: Express 5, Prisma 7 с адаптером для PostgreSQL, bcrypt, jsonwebtoken и cors, а также TypeScript и ts-node-dev для скрипта dev, который перезапускается при изменениях. Версии компонентов быстро обновляются, поэтому перед повторным использованием необходимо проверять актуальные версии.

    {
      "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"
      }
    }
    

    Обратите внимание, что ничто в этом списке не загружает файл .env в переменную process.env, поэтому для доступа к значению JWT_SECRET потребуется использование инструмента вроде dotenv или встроенной поддержки файлов среды выполнения.

    Эндпоинты для фильмов

    getMovies просто возвращает все фильмы. deleteMovie анализирует параметр маршрута: при ненумерическом ID возвращается код 400, а при неизвестном — код 404; затем выполняется $transaction, который сначала удаляет все строки списка просмотра, связанные с фильмом, а затем сам фильм. Удаление дочерних записей сначала предотвращает нарушение правил внешних ключей, а транзакция обеспечивает совместное выполнение или отклонение обоих шагов.

    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",
        });
      }
    };
    

    Альтернативой является указание onDelete: Cascade для связи movie в списке просмотра, что позволяет базе данных автоматически удалять зависимые записи. Явная транзакция лучше видна в коде, тогда как использование каскадного удаления сложнее забыть.

    Эндпоинты списка просмотра

    Контроллер списка просмотра содержит наиболее интересные правила:

    • getWatchlist фильтрует данные по ID пользователя, указанному в токене, и использует параметр include: { movie: true }, чтобы каждая запись приходила вместе с соответствующим фильмом.
    • addToWatchlist проверяет ID фильма, убеждается в его существовании, находит композитный ключ userId_movieId для исключения дубликатов с кодом ошибки 400, а затем создаёт запись с параметрами отзыва и оценки по умолчанию.
    • updateWatchlist реализует правило совместной работы: он проверяет наличие записи, но не определяет, кому она принадлежит. Если указан параметр rating, его необходимо преобразовать в целое число от 1 до 5. Поля, оставленные как undefined, игнорируются Prisma, поэтому частичная замена касается только тех данных, которые были отправлены.
    • deleteFromWatchlist загружает запись и возвращает код ошибки 403, если её userId отличается от ID вызывающего пользователя, тем самым обеспечивая соблюдение правила доступа только владельцу.
    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",
        });
      }
    };
    

    Существуют два крайних случая, о которых стоит знать. Проверка на дубликаты и операция вставки — это отдельные запросы, поэтому два одновременных запроса могут пройти проверку; уникальное ограничение всё равно защищает данные, но тот запрос, который проиграл, получит ошибку 500, если только вы не обнаружите нарушение. Кроме того, функция parseInt ведёт себя более щадяще: "4.7" преобразуется в 4, а "3abc" — в 3, поэтому более строгая проверка, такая как Number.isInteger(Number(rating)), более надёжно отклоняет некорректные данные.

    Подключение маршрутов

    Уровень маршрутизации содержит по одному файлу на каждый ресурс плюс индекс. Маршруты аутентификации размещаются под /auth, в то время как маршруты /movies и /watchlist при подключении проходят через промежуточный компонент authentication, поэтому все обработчики, находящиеся за ними, защищены без необходимости повторной проверки. Точка входа включает поддержку CORS, парсинг тел запросов в формате JSON, размещение всего под /api и прослушивание порта 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}`);
    });
    

    Из-за этих префиксов реальные пути — /api/auth/register, /api/movies и т. д., а не простые пути, указанные в спецификации. Убедитесь, что клиент и любая документация API используют один и тот же базовый URL.

    Основные выводы

    • Явная модель объединения с комплексным уникальным ограничением обеспечивает как защиту от дубликатов, так и удобный ключ для поиска.
  • Осуществите аутентификацию один раз на уровне роутера, а затем определите правила авторизации, такие как возможность удаления только владельцем, внутри конкретного контроллера.
  • Тщательно решите, как будут исчезать зависимые строки: с помощью транзакции в коде или каскадных действий в схеме.
  • В решении с ограниченным временем первыми мерами обычно являются обработка нарушений уникальности данных, предотвращение утечки необработанных ошибок, использование переменной req.user и проверка переменных окружения при запуске.