Pular para o conteúdo
Felipe Túlio

Construindo um backlog de filmes, séries e animes com Node, Prisma, MySQL e interface React

21 min de leituraProgramaçãoEngenharia de Software

Uma aplicação web de três camadas é um sistema em que o navegador, o servidor de aplicação e o banco de dados têm responsabilidades separadas e se comunicam por interfaces bem definidas. Neste artigo, construímos uma aplicação desse tipo do começo ao fim. Ela é o Backlog de filmes, séries e animes, um sistema pessoal em que cada pessoa registra o que assistiu e consulta estatísticas sobre o próprio histórico.

O projeto nasceu como material de aula em disciplinas de programação web. Por isso, ele reúne em poucas centenas de linhas os temas centrais da área. Há uma API REST (Fielding, 2000), o padrão de separação em modelo, visão e controlador (Krasner; Pope, 1988), autenticação com JSON Web Token (Jones; Bradley; Sakimura, 2015), persistência com um ORM e uma interface de página única em React. Cada tema aparece em código real, pequeno o bastante para ser lido numa tarde.

O texto foi escrito para que você consiga replicar o projeto a partir da leitura. Todo o código dos arquivos está nele. Os comandos estão na ordem em que devem ser executados. Os testes de API mostrados foram executados de verdade, e a seção de limitações registra o que foi e o que não foi verificado.

Ao final, você deverá ser capaz de:

  • descrever os requisitos funcionais e não funcionais do sistema e relacioná-los às camadas da arquitetura
  • modelar e criar o banco de dados com Prisma e migrações
  • implementar uma API REST em Express com autenticação por token, validação e tratamento de erros
  • construir um front-end React com rotas protegidas, contexto de autenticação e CRUD
  • executar a aplicação localmente e testá-la com curl

Ambiente de teste. Linux, Node.js v24.18.1, npm 11.16.0 e MySQL 8.0.42 em contêiner Docker. O projeto original rodou com Express 4.21.2 e Prisma 6.15.0. Uma instalação nova, feita a partir dos comandos deste artigo, resolveu Express 4.22.3 e Prisma 6.19.3, pois as versões usam o prefixo ^. O resultado foi o mesmo. O projeto declara Node 18 ou superior. Versões anteriores à 18 não foram testadas.

Requisitos do sistema

Antes de escrever código, convém fixar o que o sistema deve fazer. Os requisitos abaixo foram extraídos do comportamento do projeto e da documentação que o acompanha.

Requisitos funcionais

CódigoRequisito
RF01O visitante pode criar uma conta informando nome, e-mail e senha.
RF02O usuário pode entrar com e-mail e senha e recebe um token de acesso.
RF03Somente usuários autenticados acessam o backlog.
RF04O usuário cadastra um item com título, tipo (filme, série ou anime) e ano.
RF05O usuário lista, consulta, edita e remove os próprios itens.
RF06O sistema registra automaticamente a data de adição e a data da última modificação.
RF07O painel mostra o total de itens e a contagem por tipo.
RF08O usuário consulta e atualiza o próprio perfil (nome e e-mail).
RF09Um usuário nunca enxerga nem altera itens de outro.

Requisitos não funcionais

CódigoRequisito
RNF01Senhas são armazenadas somente como hash, nunca em texto claro.
RNF02A sessão é stateless: o servidor não guarda sessões, apenas valida o token.
RNF03Segredos e credenciais ficam em variáveis de ambiente, fora do código.
RNF04O back-end segue a separação em rotas, controladores e acesso a dados.
RNF05A interface é responsiva e dá retorno visual de carregamento e de erro.
RNF06A API responde com um formato uniforme e com códigos HTTP coerentes.

Regras de negócio e validações

  • O e-mail é único e é sempre gravado em minúsculas.
  • A senha tem no mínimo 6 caracteres.
  • O tipo do item pertence ao conjunto FILME, SERIE ou ANIME. A API aceita minúsculas e converte.
  • O ano está entre 1900 e o ano atual mais 5.
  • Ao excluir um usuário, seus itens são excluídos em cascata.

Arquitetura e tecnologias

A aplicação é dividida em dois projetos independentes. O back-end é uma API HTTP que expõe JSON. O front-end é uma aplicação de página única que consome essa API. O banco de dados fica atrás da API e nenhum outro componente fala com ele.

flowchart LR
    N["Navegador<br/>React + Vite<br/>porta 5173"] -->|"HTTP + JSON<br/>Authorization: Bearer"| R["Express<br/>porta 3001"]
    R --> M["Middleware<br/>log, CORS, JSON, autenticação"]
    M --> C["Controladores<br/>auth e backlog"]
    C --> P["Prisma Client"]
    P --> B[("MySQL<br/>backlog_db")]

Figura 1. Visão geral dos componentes e das portas usadas no ambiente local.

Na Figura 1, a seta mais importante é a primeira. Toda a comunicação entre as camadas visíveis ao usuário e o servidor acontece por requisições HTTP com corpo JSON. O token de autenticação viaja no cabeçalho Authorization. Esse desenho permite trocar o front-end sem tocar na API e, ao contrário, evoluir a API sem reescrever a interface.

Tecnologias do back-end

TecnologiaVersão no projetoPapel
Node.js18 ou superiorAmbiente de execução JavaScript.
Express^4.18.2 (instalada: 4.21.2)Framework HTTP, rotas e middlewares.
Prisma^6.15.0ORM, esquema do banco e migrações.
MySQL8 ou superiorBanco de dados relacional.
jsonwebtoken^9.0.2Geração e verificação de tokens JWT.
bcryptjs^3.0.2Hash de senhas com salt.
cors^2.8.5Cabeçalhos CORS.
dotenv^17.2.1Leitura do arquivo .env.
uuid^11.1.0Geração de identificadores.
nodemon^3.1.10Recarga automática em desenvolvimento.

Tecnologias do front-end

TecnologiaVersão no projetoPapel
React^19.1.0Biblioteca de interface.
Vite^6.3.5Servidor de desenvolvimento e empacotador.
React Router^7.6.1Rotas no navegador.
Axios^1.11.0Cliente HTTP com interceptadores.
Tailwind CSS^4.1.7Estilos utilitários.
shadcn/ui e Radix UIcomponentes copiados para o projetoBotões, formulários, cartões, diálogo e seleção.
Lucide React^0.510.0Ícones.

O shadcn/ui merece uma observação. Ele não é uma dependência instalada. Seus componentes são arquivos copiados para a pasta src/components/ui, e você os edita como código seu. Eles usam Radix UI para acessibilidade e Tailwind para o visual.

Estrutura de pastas

O README original cita frontend/backlog-frontend. A pasta real do projeto é frontend, e é essa que usamos aqui.

backlog-app/
├── backend/
│   ├── prisma/
│   │   ├── schema.prisma
│   │   └── migrations/
│   ├── src/
│   │   ├── config/database.js
│   │   ├── controllers/
│   │   │   ├── authController.js
│   │   │   └── backlogController.js
│   │   ├── middleware/
│   │   │   ├── auth.js
│   │   │   └── errorHandler.js
│   │   ├── routes/
│   │   │   ├── authRoutes.js
│   │   │   └── backlogRoutes.js
│   │   └── server.js
│   ├── .env
│   ├── .env.example
│   └── package.json
└── frontend/
    ├── index.html
    ├── vite.config.js
    ├── jsconfig.json
    ├── components.json
    └── src/
        ├── main.jsx
        ├── App.jsx
        ├── App.css
        ├── lib/ (api.js, utils.js)
        ├── contexts/AuthContext.jsx
        └── components/
            ├── Login.jsx
            ├── Register.jsx
            ├── Dashboard.jsx
            ├── ProtectedRoute.jsx
            └── ui/ (button, input, label, card, alert, badge, select, dialog)

Modelo de dados

O banco tem duas tabelas. A tabela users guarda as contas. A tabela backlog_items guarda os itens, e cada item pertence a exatamente um usuário.

erDiagram
    users ||--o{ backlog_items : possui
    users {
        varchar id PK
        varchar email UK
        varchar password
        varchar name
        datetime created_at
        datetime updated_at
    }
    backlog_items {
        varchar id PK
        varchar titulo
        enum tipo
        int ano
        datetime data_adicao
        datetime data_modificacao
        varchar user_id FK
    }

Figura 2. Modelo entidade-relacionamento. A notação ||--o{ lê-se “um para zero ou muitos”.

A Figura 2 mostra a cardinalidade. Um usuário pode ter nenhum, um ou vários itens. Um item sempre tem um dono. A chave estrangeira user_id materializa essa regra, e a cláusula ON DELETE CASCADE remove os itens quando o dono é removido.

O esquema Prisma

O Prisma descreve o banco em um arquivo declarativo. A partir dele, gera o SQL das migrações e o cliente de acesso. Os atributos @map e @@map conectam os nomes em camelCase do JavaScript aos nomes em snake_case do banco.

Arquivo backend/prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  password  String
  name      String
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")
  
  // Relacionamento com backlog
  backlogItems BacklogItem[]
  
  @@map("users")
}

model BacklogItem {
  id               String   @id @default(uuid())
  titulo           String
  tipo             TipoMidia
  ano              Int
  dataAdicao       DateTime @default(now()) @map("data_adicao")
  dataModificacao  DateTime @updatedAt @map("data_modificacao")
  
  // Relacionamento com usuário
  userId           String   @map("user_id")
  user             User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  
  @@map("backlog_items")
}

enum TipoMidia {
  FILME
  SERIE
  ANIME
}

Três decisões merecem atenção. O @default(uuid()) faz o próprio Prisma gerar o identificador. O @updatedAt atualiza dataModificacao em toda alteração. O enum TipoMidia vira um ENUM no MySQL, o que impede valores fora do conjunto, no modo estrito padrão do MySQL 8, mesmo que a validação da API falhe.

A migração gerada

O comando prisma migrate dev --name init, mostrado adiante, lê o esquema e escreve o SQL abaixo. Reproduzimos o arquivo para que você veja o que o ORM faz por baixo.

Arquivo backend/prisma/migrations/20250831143457_init/migration.sql (o prefixo numérico é a data de geração e muda a cada execução)

-- CreateTable
CREATE TABLE `users` (
    `id` VARCHAR(191) NOT NULL,
    `email` VARCHAR(191) NOT NULL,
    `password` VARCHAR(191) NOT NULL,
    `name` VARCHAR(191) NOT NULL,
    `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updated_at` DATETIME(3) NOT NULL,

    UNIQUE INDEX `users_email_key`(`email`),
    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `backlog_items` (
    `id` VARCHAR(191) NOT NULL,
    `titulo` VARCHAR(191) NOT NULL,
    `tipo` ENUM('FILME', 'SERIE', 'ANIME') NOT NULL,
    `ano` INTEGER NOT NULL,
    `data_adicao` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `data_modificacao` DATETIME(3) NOT NULL,
    `user_id` VARCHAR(191) NOT NULL,

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- AddForeignKey
ALTER TABLE `backlog_items` ADD CONSTRAINT `backlog_items_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

Back-end passo a passo

Passo 1: criar o projeto e instalar as dependências

mkdir backlog-app && cd backlog-app
mkdir backend && cd backend
npm init -y
npm install express@^4.18.2 cors@^2.8.5 dotenv@^17.2.1 jsonwebtoken@^9.0.2 bcryptjs@^3.0.2 uuid@^11.1.0 @prisma/client@^6.15.0 prisma@^6.15.0
npm install -D nodemon@^3.1.10

Em seguida, edite o package.json gerado para ter o campo scripts abaixo. Os demais campos podem permanecer como estão.

Arquivo backend/package.json

{
  "name": "backend",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:studio": "prisma studio",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@prisma/client": "^6.15.0",
    "bcryptjs": "^3.0.2",
    "cors": "^2.8.5",
    "dotenv": "^17.2.1",
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.2",
    "prisma": "^6.15.0",
    "uuid": "^11.1.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.10"
  }
}

O npm install pode exibir avisos sobre scripts de instalação do Prisma (allow-scripts). Eles não impediram o funcionamento no ambiente de teste, desde que o cliente fosse gerado com npx prisma generate, como no passo 4.

Passo 2: variáveis de ambiente

Segredos não entram no código. O arquivo .env guarda a URL do banco e a chave de assinatura dos tokens. O .env.example é versionado como modelo, e o .env real fica fora do Git graças ao .gitignore.

Arquivo backend/.env.example

# Database Configuration
DATABASE_URL="mysql://username:password@localhost:3306/backlog_db"

# JWT Secret (IMPORTANTE: Use uma chave segura e única em produção)
JWT_SECRET="sua_chave_secreta_jwt_muito_segura_aqui_123456789"

# Server Configuration
PORT=3001
NODE_ENV=development

# Exemplo para produção:
# DATABASE_URL="mysql://user:password@production-host:3306/backlog_production"
# JWT_SECRET="chave_super_secreta_e_complexa_para_producao_com_pelo_menos_32_caracteres"
# NODE_ENV=production

Crie o backend/.env com os valores do seu ambiente. Para o banco em Docker deste artigo, use:

Arquivo backend/.env

DATABASE_URL="mysql://root:password@localhost:3306/backlog_db"
JWT_SECRET="troque_por_uma_chave_longa_e_aleatoria"
PORT=3001
NODE_ENV=development

Arquivo backend/.gitignore

node_modules
# Keep environment variables out of version control
.env

/src/generated/prisma

Passo 3: subir o MySQL

A forma mais simples de ter um MySQL descartável é um contêiner Docker. O comando cria o banco backlog_db já na inicialização.

docker run -d --name backlog-db \
  -e MYSQL_ROOT_PASSWORD=password \
  -e MYSQL_DATABASE=backlog_db \
  -p 3306:3306 mysql:8.0

A primeira inicialização leva alguns segundos. Aguarde até o comando abaixo responder sem erro.

docker exec backlog-db mysql -uroot -ppassword -h127.0.0.1 -e "use backlog_db; select version()"

Se a porta 3306 já estiver ocupada na sua máquina, mapeie outra (por exemplo -p 3308:3306) e ajuste a porta na DATABASE_URL. Sem Docker, instale o MySQL 8 pelo gerenciador de pacotes do seu sistema e crie o banco com CREATE DATABASE backlog_db;.

Passo 4: esquema, migração e cliente Prisma

Crie a pasta prisma com o arquivo schema.prisma mostrado na seção anterior. Depois, gere a migração e o cliente.

npx prisma migrate dev --name init
npx prisma generate

O primeiro comando cria a pasta prisma/migrations, aplica o SQL no banco e atualiza o cliente. Em outra máquina, onde as migrações já existem no repositório, use npx prisma migrate deploy, que apenas aplica o que falta.

Passo 5: acesso ao banco

Um único PrismaClient é criado e reutilizado por toda a aplicação. O módulo também expõe funções de conexão e encerramento e trata os sinais SIGINT e SIGTERM para fechar a conexão com elegância.

Arquivo backend/src/config/database.js

const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
});

// Função para conectar ao banco
const connectDatabase = async () => {
  try {
    await prisma.$connect();
    console.log('✅ Conectado ao banco de dados MySQL');
  } catch (error) {
    console.error('❌ Erro ao conectar ao banco de dados:', error);
    process.exit(1);
  }
};

// Função para desconectar do banco
const disconnectDatabase = async () => {
  try {
    await prisma.$disconnect();
    console.log('✅ Desconectado do banco de dados');
  } catch (error) {
    console.error('❌ Erro ao desconectar do banco de dados:', error);
  }
};

// Graceful shutdown
process.on('SIGINT', async () => {
  console.log('🔄 Encerrando aplicação...');
  await disconnectDatabase();
  process.exit(0);
});

process.on('SIGTERM', async () => {
  console.log('🔄 Encerrando aplicação...');
  await disconnectDatabase();
  process.exit(0);
});

module.exports = {
  prisma,
  connectDatabase,
  disconnectDatabase
};

Em desenvolvimento, o cliente registra as consultas SQL no console. Isso ajuda muito em aula, porque o aluno vê o SQL que cada chamada do ORM produz.

Passo 6: servidor Express

O server.js monta a aplicação. A ordem dos app.use importa. Primeiro vêm CORS e parsers de corpo, depois o log, depois as rotas e, por último, o tratador de erros e a rota 404.

Arquivo backend/src/server.js

const express = require('express');
const cors = require('cors');
const dotenv = require('dotenv');

// Carregar variáveis de ambiente
dotenv.config();

// Importar configuração do banco
const { connectDatabase } = require('./config/database');

// Importar rotas
const authRoutes = require('./routes/authRoutes');
const backlogRoutes = require('./routes/backlogRoutes');

// Importar middleware de erro
const errorHandler = require('./middleware/errorHandler');

const app = express();
const PORT = process.env.PORT || 3001;

// Conectar ao banco de dados
connectDatabase();

// Middleware global
app.use(cors({
  origin: '*', // Permitir todas as origens para desenvolvimento
  credentials: true
}));

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Middleware de log para desenvolvimento
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
  next();
});

// Rotas
app.use('/api/auth', authRoutes);
app.use('/api/backlog', backlogRoutes);

// Rota de teste
app.get('/api/health', (req, res) => {
  res.json({ 
    message: 'API do Backlog funcionando!', 
    timestamp: new Date().toISOString() 
  });
});

// Middleware de tratamento de erros
app.use(errorHandler);

// Middleware para rotas não encontradas
app.use('*', (req, res) => {
  res.status(404).json({ 
    error: 'Rota não encontrada',
    path: req.originalUrl 
  });
});

// Iniciar servidor
app.listen(PORT, '0.0.0.0', () => {
  console.log(`🚀 Servidor rodando na porta ${PORT}`);
  console.log(`📊 Ambiente: ${process.env.NODE_ENV || 'development'}`);
  console.log(`🔗 URL: http://localhost:${PORT}`);
});

module.exports = app;

Observe duas escolhas. O servidor escuta em 0.0.0.0, o que o torna acessível de outras máquinas da rede. E o CORS está liberado para qualquer origem, o que é cômodo em desenvolvimento e inadequado em produção. Voltamos a isso nas limitações.

Passo 7: middleware de autenticação

O middleware authenticateToken é o porteiro das rotas protegidas. Ele extrai o token do cabeçalho Authorization: Bearer <token>, verifica a assinatura e a validade e confirma que o usuário ainda existe. Se tudo estiver certo, anexa o usuário em req.user e chama next().

Arquivo backend/src/middleware/auth.js

const jwt = require('jsonwebtoken');
const { prisma } = require('../config/database');

// Middleware para verificar token JWT
const authenticateToken = async (req, res, next) => {
  try {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

    if (!token) {
      return res.status(401).json({
        success: false,
        error: 'Token de acesso requerido',
        message: 'Você precisa estar logado para acessar este recurso'
      });
    }

    // Verificar e decodificar o token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Buscar usuário no banco de dados
    const usuario = await prisma.user.findUnique({
      where: { id: decoded.id },
      select: {
        id: true,
        name: true,
        email: true,
        createdAt: true,
        updatedAt: true
      }
    });

    if (!usuario) {
      return res.status(401).json({
        success: false,
        error: 'Usuário não encontrado',
        message: 'Token inválido ou usuário não existe'
      });
    }

    // Adicionar usuário ao objeto request
    req.user = usuario;
    next();
  } catch (error) {
    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({
        success: false,
        error: 'Token inválido',
        message: 'Token de autenticação inválido'
      });
    }

    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        error: 'Token expirado',
        message: 'Seu token de autenticação expirou. Faça login novamente'
      });
    }

    // Erro genérico
    return res.status(500).json({
      success: false,
      error: 'Erro interno do servidor',
      message: 'Erro ao verificar autenticação'
    });
  }
};

// Middleware opcional para verificar token (não obrigatório)
const optionalAuth = async (req, res, next) => {
  try {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {
      req.user = null;
      return next();
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const usuario = await prisma.user.findUnique({
      where: { id: decoded.id },
      select: {
        id: true,
        name: true,
        email: true,
        createdAt: true,
        updatedAt: true
      }
    });

    req.user = usuario || null;
    next();
  } catch (error) {
    req.user = null;
    next();
  }
};

module.exports = {
  authenticateToken,
  optionalAuth
};

O token carrega apenas o id do usuário. Os dados completos são lidos do banco a cada requisição, com select explícito para que a senha nunca seja carregada. A função optionalAuth existe no arquivo, mas nenhuma rota a utiliza.

Passo 8: tratamento centralizado de erros

Os controladores repassam exceções com next(error). O tratador converte os erros mais comuns do Prisma e do JWT em respostas HTTP adequadas. O código P2002 indica violação de unicidade e P2025 indica registro não encontrado. Em desenvolvimento, a resposta inclui a pilha de chamadas.

Arquivo backend/src/middleware/errorHandler.js

const errorHandler = (err, req, res, next) => {
  console.error('Erro capturado:', err);

  // Erro de validação do Prisma
  if (err.code === 'P2002') {
    return res.status(400).json({
      error: 'Dados duplicados',
      message: 'Este registro já existe no banco de dados'
    });
  }

  // Erro de registro não encontrado do Prisma
  if (err.code === 'P2025') {
    return res.status(404).json({
      error: 'Registro não encontrado',
      message: 'O registro solicitado não foi encontrado'
    });
  }

  // Erro de JWT
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      error: 'Token inválido',
      message: 'Token de autenticação inválido'
    });
  }

  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({
      error: 'Token expirado',
      message: 'Token de autenticação expirado'
    });
  }

  // Erro de validação
  if (err.name === 'ValidationError') {
    return res.status(400).json({
      error: 'Erro de validação',
      message: err.message
    });
  }

  // Erro padrão
  const statusCode = err.statusCode || 500;
  const message = err.message || 'Erro interno do servidor';

  res.status(statusCode).json({
    error: statusCode === 500 ? 'Erro interno do servidor' : 'Erro',
    message: message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
};

module.exports = errorHandler;

Passo 9: controlador de autenticação

Este controlador implementa registro, login, leitura e atualização de perfil. Há quatro pontos didáticos. A senha é transformada em hash com bcrypt e custo 12 antes de ser gravada. O e-mail é normalizado para minúsculas. A resposta nunca inclui o hash, que é removido por desestruturação. E o login responde com a mesma mensagem genérica para e-mail inexistente e para senha errada, o que não revela quais e-mails estão cadastrados.

Arquivo backend/src/controllers/authController.js

const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { prisma } = require('../config/database');
const { v4: uuidv4 } = require('uuid');

// Função para gerar token JWT
const generateToken = (userId) => {
  return jwt.sign(
    { id: userId },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );
};

// Registro de usuário
const register = async (req, res, next) => {
  try {
    const { name, email, password } = req.body;

    // Validação básica
    if (!name || !email || !password) {
      return res.status(400).json({
        success: false,
        error: 'Nome, email e senha são obrigatórios'
      });
    }

    // Validar formato do email
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      return res.status(400).json({
        success: false,
        error: 'Formato de email inválido'
      });
    }

    // Validar senha
    if (password.length < 6) {
      return res.status(400).json({
        success: false,
        error: 'A senha deve ter pelo menos 6 caracteres'
      });
    }

    // Verificar se o usuário já existe
    const usuarioExistente = await prisma.user.findUnique({
      where: { email: email.toLowerCase() }
    });

    if (usuarioExistente) {
      return res.status(400).json({
        success: false,
        error: 'Este email já está cadastrado'
      });
    }

    // Criptografar senha
    const saltRounds = 12;
    const hashedPassword = await bcrypt.hash(password, saltRounds);

    // Criar usuário
    const novoUsuario = await prisma.user.create({
      data: {
        id: uuidv4(),
        name: name.trim(),
        email: email.toLowerCase().trim(),
        password: hashedPassword
      }
    });

    // Gerar token
    const token = generateToken(novoUsuario.id);

    // Remover senha da resposta
    const { password: _, ...usuarioSemSenha } = novoUsuario;

    res.status(201).json({
      success: true,
      data: {
        user: usuarioSemSenha,
        token
      },
      message: 'Usuário registrado com sucesso'
    });
  } catch (error) {
    next(error);
  }
};

// Login de usuário
const login = async (req, res, next) => {
  try {
    const { email, password } = req.body;

    // Validação básica
    if (!email || !password) {
      return res.status(400).json({
        success: false,
        error: 'Email e senha são obrigatórios'
      });
    }

    // Buscar usuário
    const usuario = await prisma.user.findUnique({
      where: { email: email.toLowerCase() }
    });

    if (!usuario) {
      return res.status(401).json({
        success: false,
        error: 'Credenciais inválidas'
      });
    }

    // Verificar senha
    const senhaValida = await bcrypt.compare(password, usuario.password);

    if (!senhaValida) {
      return res.status(401).json({
        success: false,
        error: 'Credenciais inválidas'
      });
    }

    // Gerar token
    const token = generateToken(usuario.id);

    // Remover senha da resposta
    const { password: _, ...usuarioSemSenha } = usuario;

    res.json({
      success: true,
      data: {
        user: usuarioSemSenha,
        token
      },
      message: 'Login realizado com sucesso'
    });
  } catch (error) {
    next(error);
  }
};

// Obter perfil do usuário
const getProfile = async (req, res, next) => {
  try {
    const userId = req.user.id;

    const usuario = await prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        name: true,
        email: true,
        createdAt: true,
        updatedAt: true
      }
    });

    if (!usuario) {
      return res.status(404).json({
        success: false,
        error: 'Usuário não encontrado'
      });
    }

    res.json({
      success: true,
      data: usuario
    });
  } catch (error) {
    next(error);
  }
};

// Atualizar perfil do usuário
const updateProfile = async (req, res, next) => {
  try {
    const userId = req.user.id;
    const { name, email } = req.body;

    // Preparar dados para atualização
    const dadosAtualizacao = {};

    if (name) {
      dadosAtualizacao.name = name.trim();
    }

    if (email) {
      // Validar formato do email
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(email)) {
        return res.status(400).json({
          success: false,
          error: 'Formato de email inválido'
        });
      }

      // Verificar se o email já está em uso por outro usuário
      const emailExistente = await prisma.user.findFirst({
        where: {
          email: email.toLowerCase(),
          NOT: { id: userId }
        }
      });

      if (emailExistente) {
        return res.status(400).json({
          success: false,
          error: 'Este email já está em uso'
        });
      }

      dadosAtualizacao.email = email.toLowerCase().trim();
    }

    const usuarioAtualizado = await prisma.user.update({
      where: { id: userId },
      data: dadosAtualizacao,
      select: {
        id: true,
        name: true,
        email: true,
        createdAt: true,
        updatedAt: true
      }
    });

    res.json({
      success: true,
      data: usuarioAtualizado,
      message: 'Perfil atualizado com sucesso'
    });
  } catch (error) {
    next(error);
  }
};

module.exports = {
  register,
  login,
  getProfile,
  updateProfile
};

Passo 10: controlador do backlog

Aqui está o CRUD. Toda consulta inclui userId no filtro, e é isso que garante o requisito RF09. Um item de outro usuário simplesmente não é encontrado, e a API responde 404, e não 403, para não confirmar sua existência. O método getBacklogStats usa groupBy para contar itens por tipo diretamente no banco.

Arquivo backend/src/controllers/backlogController.js

const { prisma } = require('../config/database');
const { v4: uuidv4 } = require('uuid');

// Listar todos os itens do backlog do usuário
const getBacklogItems = async (req, res, next) => {
  try {
    const userId = req.user.id;
    
    const items = await prisma.backlogItem.findMany({
      where: { userId },
      orderBy: { dataAdicao: 'desc' }
    });

    res.json({
      success: true,
      data: items,
      total: items.length
    });
  } catch (error) {
    next(error);
  }
};

// Obter um item específico do backlog
const getBacklogItem = async (req, res, next) => {
  try {
    const { id } = req.params;
    const userId = req.user.id;

    const item = await prisma.backlogItem.findFirst({
      where: { 
        id,
        userId 
      }
    });

    if (!item) {
      return res.status(404).json({
        success: false,
        error: 'Item não encontrado'
      });
    }

    res.json({
      success: true,
      data: item
    });
  } catch (error) {
    next(error);
  }
};

// Criar novo item no backlog
const createBacklogItem = async (req, res, next) => {
  try {
    const { titulo, tipo, ano } = req.body;
    const userId = req.user.id;

    // Validação básica
    if (!titulo || !tipo || !ano) {
      return res.status(400).json({
        success: false,
        error: 'Título, tipo e ano são obrigatórios'
      });
    }

    // Validar tipo
    const tiposValidos = ['FILME', 'SERIE', 'ANIME'];
    if (!tiposValidos.includes(tipo.toUpperCase())) {
      return res.status(400).json({
        success: false,
        error: 'Tipo deve ser: FILME, SERIE ou ANIME'
      });
    }

    // Validar ano
    const anoAtual = new Date().getFullYear();
    if (ano < 1900 || ano > anoAtual + 5) {
      return res.status(400).json({
        success: false,
        error: `Ano deve estar entre 1900 e ${anoAtual + 5}`
      });
    }

    const novoItem = await prisma.backlogItem.create({
      data: {
        id: uuidv4(),
        titulo: titulo.trim(),
        tipo: tipo.toUpperCase(),
        ano: parseInt(ano),
        userId
      }
    });

    res.status(201).json({
      success: true,
      data: novoItem,
      message: 'Item adicionado ao backlog com sucesso'
    });
  } catch (error) {
    next(error);
  }
};

// Atualizar item do backlog
const updateBacklogItem = async (req, res, next) => {
  try {
    const { id } = req.params;
    const { titulo, tipo, ano } = req.body;
    const userId = req.user.id;

    // Verificar se o item existe e pertence ao usuário
    const itemExistente = await prisma.backlogItem.findFirst({
      where: { 
        id,
        userId 
      }
    });

    if (!itemExistente) {
      return res.status(404).json({
        success: false,
        error: 'Item não encontrado'
      });
    }

    // Preparar dados para atualização
    const dadosAtualizacao = {};
    
    if (titulo) {
      dadosAtualizacao.titulo = titulo.trim();
    }
    
    if (tipo) {
      const tiposValidos = ['FILME', 'SERIE', 'ANIME'];
      if (!tiposValidos.includes(tipo.toUpperCase())) {
        return res.status(400).json({
          success: false,
          error: 'Tipo deve ser: FILME, SERIE ou ANIME'
        });
      }
      dadosAtualizacao.tipo = tipo.toUpperCase();
    }
    
    if (ano) {
      const anoAtual = new Date().getFullYear();
      if (ano < 1900 || ano > anoAtual + 5) {
        return res.status(400).json({
          success: false,
          error: `Ano deve estar entre 1900 e ${anoAtual + 5}`
        });
      }
      dadosAtualizacao.ano = parseInt(ano);
    }

    const itemAtualizado = await prisma.backlogItem.update({
      where: { id },
      data: dadosAtualizacao
    });

    res.json({
      success: true,
      data: itemAtualizado,
      message: 'Item atualizado com sucesso'
    });
  } catch (error) {
    next(error);
  }
};

// Deletar item do backlog
const deleteBacklogItem = async (req, res, next) => {
  try {
    const { id } = req.params;
    const userId = req.user.id;

    // Verificar se o item existe e pertence ao usuário
    const itemExistente = await prisma.backlogItem.findFirst({
      where: { 
        id,
        userId 
      }
    });

    if (!itemExistente) {
      return res.status(404).json({
        success: false,
        error: 'Item não encontrado'
      });
    }

    await prisma.backlogItem.delete({
      where: { id }
    });

    res.json({
      success: true,
      message: 'Item removido do backlog com sucesso'
    });
  } catch (error) {
    next(error);
  }
};

// Obter estatísticas do backlog
const getBacklogStats = async (req, res, next) => {
  try {
    const userId = req.user.id;

    const stats = await prisma.backlogItem.groupBy({
      by: ['tipo'],
      where: { userId },
      _count: {
        tipo: true
      }
    });

    const total = await prisma.backlogItem.count({
      where: { userId }
    });

    const estatisticas = {
      total,
      porTipo: stats.reduce((acc, stat) => {
        acc[stat.tipo.toLowerCase()] = stat._count.tipo;
        return acc;
      }, {})
    };

    res.json({
      success: true,
      data: estatisticas
    });
  } catch (error) {
    next(error);
  }
};

module.exports = {
  getBacklogItems,
  getBacklogItem,
  createBacklogItem,
  updateBacklogItem,
  deleteBacklogItem,
  getBacklogStats
};

Passo 11: rotas

As rotas só mapeiam caminhos e verbos para funções. Em backlogRoutes.js, router.use(authenticateToken) protege todas as rotas do arquivo de uma só vez. A rota /stats é declarada antes de /:id. Se a ordem fosse inversa, o Express interpretaria stats como um identificador.

Arquivo backend/src/routes/authRoutes.js

const express = require('express');
const router = express.Router();

// Importar controllers
const {
  register,
  login,
  getProfile,
  updateProfile
} = require('../controllers/authController');

// Importar middleware de autenticação
const { authenticateToken } = require('../middleware/auth');

// Rotas públicas (não requerem autenticação)
router.post('/register', register);  // POST /api/auth/register - Registro
router.post('/login', login);        // POST /api/auth/login - Login

// Rotas protegidas (requerem autenticação)
router.get('/profile', authenticateToken, getProfile);       // GET /api/auth/profile - Obter perfil
router.put('/profile', authenticateToken, updateProfile);    // PUT /api/auth/profile - Atualizar perfil

// Rota para verificar se o token é válido
router.get('/verify', authenticateToken, (req, res) => {
  res.json({
    success: true,
    data: {
      user: req.user,
      valid: true
    },
    message: 'Token válido'
  });
});

module.exports = router;

Arquivo backend/src/routes/backlogRoutes.js

const express = require('express');
const router = express.Router();

// Importar controllers
const {
  getBacklogItems,
  getBacklogItem,
  createBacklogItem,
  updateBacklogItem,
  deleteBacklogItem,
  getBacklogStats
} = require('../controllers/backlogController');

// Importar middleware de autenticação
const { authenticateToken } = require('../middleware/auth');

// Aplicar middleware de autenticação a todas as rotas
router.use(authenticateToken);

// Rotas do backlog
router.get('/', getBacklogItems);           // GET /api/backlog - Listar todos os itens
router.get('/stats', getBacklogStats);     // GET /api/backlog/stats - Estatísticas
router.get('/:id', getBacklogItem);        // GET /api/backlog/:id - Obter item específico
router.post('/', createBacklogItem);       // POST /api/backlog - Criar novo item
router.put('/:id', updateBacklogItem);     // PUT /api/backlog/:id - Atualizar item
router.delete('/:id', deleteBacklogItem);  // DELETE /api/backlog/:id - Deletar item

module.exports = router;

Contrato da API

Todas as rotas têm o prefixo /api. As respostas de sucesso seguem o formato { "success": true, "data": ..., "message": ... }. As de erro trazem { "success": false, "error": "..." }. O formato é quase uniforme. As respostas do tratador central e da rota 404 não incluem o campo success, como se vê no código.

Endpoints

MétodoCaminhoAutenticaçãoCorpoSucessoErros
GET/api/healthnãon/d200n/d
POST/api/auth/registernãoname, email, password201400
POST/api/auth/loginnãoemail, password200400, 401
GET/api/auth/profilesimn/d200401, 404
PUT/api/auth/profilesimname e/ou email200400, 401
GET/api/auth/verifysimn/d200401
GET/api/backlogsimn/d200401
GET/api/backlog/statssimn/d200401
GET/api/backlog/:idsimn/d200401, 404
POST/api/backlogsimtitulo, tipo, ano201400, 401
PUT/api/backlog/:idsimtitulo, tipo e/ou ano200400, 401, 404
DELETE/api/backlog/:idsimn/d200401, 404

Rotas inexistentes respondem 404 com { "error": "Rota não encontrada", "path": "..." }.

Como o login e o acesso protegido funcionam

O diagrama de sequência mostra o ciclo completo. Primeiro o cliente obtém um token. Depois ele o apresenta a cada requisição.

sequenceDiagram
    participant C as Cliente (React)
    participant A as API Express
    participant D as MySQL
    C->>A: POST /api/auth/login (email, password)
    A->>D: busca usuário pelo e-mail
    D-->>A: usuário com hash da senha
    A->>A: bcrypt.compare e jwt.sign (7 dias)
    A-->>C: 200 com user e token
    C->>C: grava token no localStorage
    C->>A: GET /api/backlog (Authorization: Bearer token)
    A->>A: jwt.verify e busca o usuário
    A->>D: itens WHERE user_id = id do token
    D-->>A: itens
    A-->>C: 200 com data e total

Figura 3. Fluxo de autenticação e de acesso a uma rota protegida.

Dois detalhes da Figura 3 são decisivos. O token é assinado com o JWT_SECRET e expira em 7 dias. E o filtro user_id = id do token é o que isola os dados. O identificador do usuário vem do token, nunca do corpo da requisição, então o cliente não consegue pedir os itens de outra pessoa.

Front-end passo a passo

Passo 12: criar o projeto Vite e instalar as dependências

O projeto usa pnpm. O npm também funciona, com uma ressalva explicada na seção de execução.

cd ..   # volte para a pasta backlog-app
pnpm create vite@6 frontend --template react
cd frontend
pnpm add axios@^1.11.0 react-router-dom@^7.6.1 lucide-react@^0.510.0 \
  class-variance-authority@^0.7.1 clsx@^2.1.1 tailwind-merge@^3.3.0 \
  @radix-ui/react-dialog@^1.1.13 @radix-ui/react-label@^2.1.6 \
  @radix-ui/react-select@^2.2.4 @radix-ui/react-slot@^1.2.2 \
  tailwindcss@^4.1.7 @tailwindcss/vite@^4.1.7
pnpm add -D tw-animate-css@^1.2.9

O pnpm moderno bloqueia scripts de instalação de dependências e pode terminar com o erro ERR_PNPM_IGNORED_BUILDS para o esbuild. Na versão 11.9.0, esse bloqueio fez o pnpm build falhar na primeira tentativa, porque o comando verifica as dependências antes de rodar. A correção testada foi autorizar os scripts uma única vez.

pnpm approve-builds --all

O comando grava allowBuilds no arquivo pnpm-workspace.yaml e executa o instalador do esbuild. O projeto original resolve o mesmo problema de outro modo, com pnpm.ignoredBuiltDependencies no package.json, que é a forma de versões 10 do pnpm. Na versão 11.9.0, essa chave não surtiu efeito no teste.

Passo 13: configuração do Vite, alias e HTML

Duas configurações merecem explicação. O plugin @tailwindcss/vite ativa o Tailwind 4 sem arquivo de configuração. E o alias @ aponta para src, o que permite importar @/components/ui/button em vez de caminhos relativos longos.

Arquivo frontend/vite.config.js

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(),tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})

Arquivo frontend/jsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Arquivo frontend/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Backlog de Filmes, Séries e Animes</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

O index.html referencia /favicon.ico. Se você não tiver esse arquivo em public/, remova a linha do <link rel="icon">. O navegador apenas registraria um 404 para o ícone.

Passo 14: estilos e utilitário cn

O tema do shadcn/ui, com variáveis CSS para cores e raios, está em App.css. O index.css do projeto está vazio e apenas é importado por main.jsx. Para reproduzir, deixe src/index.css vazio, substituindo o conteúdo que o modelo do Vite gera.

Arquivo frontend/src/App.css

@import "tailwindcss";
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));

@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --color-chart-1: var(--chart-1);
  --color-chart-2: var(--chart-2);
  --color-chart-3: var(--chart-3);
  --color-chart-4: var(--chart-4);
  --color-chart-5: var(--chart-5);
  --color-sidebar: var(--sidebar);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-ring: var(--sidebar-ring);
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.205 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.269 0 0);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.556 0 0);
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}

A função cn combina classes condicionais com clsx e resolve conflitos do Tailwind com tailwind-merge. Todos os componentes de interface a usam.

Arquivo frontend/src/lib/utils.js

import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"

export function cn(...inputs) {
  return twMerge(clsx(inputs));
}

Passo 15: componentes de interface (shadcn/ui)

O projeto usa oito componentes do shadcn/ui: button, input, label, card, alert, badge, select e dialog. A pasta original contém dezenas de outros, gerados em lote e não utilizados. Os oito necessários estão a seguir, na íntegra. Se preferir, o comando npx shadcn@latest add button input label card alert badge select dialog gera componentes equivalentes, mas as versões mais novas do gerador produzem arquivos diferentes dos mostrados aqui. Este artigo não validou esse caminho. Para reprodução fiel, copie os arquivos abaixo.

Arquivo frontend/src/components/ui/button.jsx

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
        destructive:
          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}) {
  const Comp = asChild ? Slot : "button"

  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props} />
  );
}

export { Button, buttonVariants }

Arquivo frontend/src/components/ui/input.jsx

import * as React from "react"

import { cn } from "@/lib/utils"

function Input({
  className,
  type,
  ...props
}) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
        className
      )}
      {...props} />
  );
}

export { Input }

Arquivo frontend/src/components/ui/label.jsx

"use client"

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"

import { cn } from "@/lib/utils"

function Label({
  className,
  ...props
}) {
  return (
    <LabelPrimitive.Root
      data-slot="label"
      className={cn(
        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
        className
      )}
      {...props} />
  );
}

export { Label }

Arquivo frontend/src/components/ui/card.jsx

import * as React from "react"

import { cn } from "@/lib/utils"

function Card({
  className,
  ...props
}) {
  return (
    <div
      data-slot="card"
      className={cn(
        "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
        className
      )}
      {...props} />
  );
}

function CardHeader({
  className,
  ...props
}) {
  return (
    <div
      data-slot="card-header"
      className={cn(
        "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
        className
      )}
      {...props} />
  );
}

function CardTitle({
  className,
  ...props
}) {
  return (
    <div
      data-slot="card-title"
      className={cn("leading-none font-semibold", className)}
      {...props} />
  );
}

function CardDescription({
  className,
  ...props
}) {
  return (
    <div
      data-slot="card-description"
      className={cn("text-muted-foreground text-sm", className)}
      {...props} />
  );
}

function CardAction({
  className,
  ...props
}) {
  return (
    <div
      data-slot="card-action"
      className={cn(
        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
        className
      )}
      {...props} />
  );
}

function CardContent({
  className,
  ...props
}) {
  return (<div data-slot="card-content" className={cn("px-6", className)} {...props} />);
}

function CardFooter({
  className,
  ...props
}) {
  return (
    <div
      data-slot="card-footer"
      className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
      {...props} />
  );
}

export {
  Card,
  CardHeader,
  CardFooter,
  CardTitle,
  CardAction,
  CardDescription,
  CardContent,
}

Arquivo frontend/src/components/ui/alert.jsx

import * as React from "react"
import { cva } from "class-variance-authority";

import { cn } from "@/lib/utils"

const alertVariants = cva(
  "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
  {
    variants: {
      variant: {
        default: "bg-card text-card-foreground",
        destructive:
          "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function Alert({
  className,
  variant,
  ...props
}) {
  return (
    <div
      data-slot="alert"
      role="alert"
      className={cn(alertVariants({ variant }), className)}
      {...props} />
  );
}

function AlertTitle({
  className,
  ...props
}) {
  return (
    <div
      data-slot="alert-title"
      className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
      {...props} />
  );
}

function AlertDescription({
  className,
  ...props
}) {
  return (
    <div
      data-slot="alert-description"
      className={cn(
        "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
        className
      )}
      {...props} />
  );
}

export { Alert, AlertTitle, AlertDescription }

Arquivo frontend/src/components/ui/badge.jsx

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";

import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
  {
    variants: {
      variant: {
        default:
          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
        secondary:
          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
        destructive:
          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function Badge({
  className,
  variant,
  asChild = false,
  ...props
}) {
  const Comp = asChild ? Slot : "span"

  return (
    <Comp
      data-slot="badge"
      className={cn(badgeVariants({ variant }), className)}
      {...props} />
  );
}

export { Badge, badgeVariants }

Arquivo frontend/src/components/ui/select.jsx

import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"

import { cn } from "@/lib/utils"

function Select({
  ...props
}) {
  return <SelectPrimitive.Root data-slot="select" {...props} />;
}

function SelectGroup({
  ...props
}) {
  return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}

function SelectValue({
  ...props
}) {
  return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}

function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}>
      {children}
      <SelectPrimitive.Icon asChild>
        <ChevronDownIcon className="size-4 opacity-50" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  );
}

function SelectContent({
  className,
  children,
  position = "popper",
  ...props
}) {
  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Content
        data-slot="select-content"
        className={cn(
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
          position === "popper" &&
            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
          className
        )}
        position={position}
        {...props}>
        <SelectScrollUpButton />
        <SelectPrimitive.Viewport
          className={cn("p-1", position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1")}>
          {children}
        </SelectPrimitive.Viewport>
        <SelectScrollDownButton />
      </SelectPrimitive.Content>
    </SelectPrimitive.Portal>
  );
}

function SelectLabel({
  className,
  ...props
}) {
  return (
    <SelectPrimitive.Label
      data-slot="select-label"
      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
      {...props} />
  );
}

function SelectItem({
  className,
  children,
  ...props
}) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      className={cn(
        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
        className
      )}
      {...props}>
      <span className="absolute right-2 flex size-3.5 items-center justify-center">
        <SelectPrimitive.ItemIndicator>
          <CheckIcon className="size-4" />
        </SelectPrimitive.ItemIndicator>
      </span>
      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
    </SelectPrimitive.Item>
  );
}

function SelectSeparator({
  className,
  ...props
}) {
  return (
    <SelectPrimitive.Separator
      data-slot="select-separator"
      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
      {...props} />
  );
}

function SelectScrollUpButton({
  className,
  ...props
}) {
  return (
    <SelectPrimitive.ScrollUpButton
      data-slot="select-scroll-up-button"
      className={cn("flex cursor-default items-center justify-center py-1", className)}
      {...props}>
      <ChevronUpIcon className="size-4" />
    </SelectPrimitive.ScrollUpButton>
  );
}

function SelectScrollDownButton({
  className,
  ...props
}) {
  return (
    <SelectPrimitive.ScrollDownButton
      data-slot="select-scroll-down-button"
      className={cn("flex cursor-default items-center justify-center py-1", className)}
      {...props}>
      <ChevronDownIcon className="size-4" />
    </SelectPrimitive.ScrollDownButton>
  );
}

export {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectScrollDownButton,
  SelectScrollUpButton,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
}

Arquivo frontend/src/components/ui/dialog.jsx

import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"

import { cn } from "@/lib/utils"

function Dialog({
  ...props
}) {
  return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}

function DialogTrigger({
  ...props
}) {
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}

function DialogPortal({
  ...props
}) {
  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}

function DialogClose({
  ...props
}) {
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}

function DialogOverlay({
  className,
  ...props
}) {
  return (
    <DialogPrimitive.Overlay
      data-slot="dialog-overlay"
      className={cn(
        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
        className
      )}
      {...props} />
  );
}

function DialogContent({
  className,
  children,
  ...props
}) {
  return (
    <DialogPortal data-slot="dialog-portal">
      <DialogOverlay />
      <DialogPrimitive.Content
        data-slot="dialog-content"
        className={cn(
          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
          className
        )}
        {...props}>
        {children}
        <DialogPrimitive.Close
          className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
          <XIcon />
          <span className="sr-only">Close</span>
        </DialogPrimitive.Close>
      </DialogPrimitive.Content>
    </DialogPortal>
  );
}

function DialogHeader({
  className,
  ...props
}) {
  return (
    <div
      data-slot="dialog-header"
      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
      {...props} />
  );
}

function DialogFooter({
  className,
  ...props
}) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
      {...props} />
  );
}

function DialogTitle({
  className,
  ...props
}) {
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      className={cn("text-lg leading-none font-semibold", className)}
      {...props} />
  );
}

function DialogDescription({
  className,
  ...props
}) {
  return (
    <DialogPrimitive.Description
      data-slot="dialog-description"
      className={cn("text-muted-foreground text-sm", className)}
      {...props} />
  );
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
}

Passo 16: cliente HTTP

O arquivo api.js centraliza toda a comunicação com a API. Ele cria uma instância do Axios com a URL base e dois interceptadores. O de requisição lê o token do localStorage e o acrescenta ao cabeçalho Authorization. O de resposta trata o status 401, limpando a sessão e redirecionando para /login. Em seguida, os objetos authService e backlogService expõem uma função por endpoint.

Arquivo frontend/src/lib/api.js

import axios from 'axios';

// Configuração base da API
const API_BASE_URL = 'http://localhost:3001/api';

// Criar instância do axios
const api = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Interceptor para adicionar token de autenticação
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// Interceptor para tratar respostas e erros
api.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    if (error.response?.status === 401) {
      // Token inválido ou expirado
      localStorage.removeItem('token');
      localStorage.removeItem('user');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

// Serviços de autenticação
export const authService = {
  register: async (userData) => {
    const response = await api.post('/auth/register', userData);
    return response.data;
  },

  login: async (credentials) => {
    const response = await api.post('/auth/login', credentials);
    return response.data;
  },

  getProfile: async () => {
    const response = await api.get('/auth/profile');
    return response.data;
  },

  updateProfile: async (userData) => {
    const response = await api.put('/auth/profile', userData);
    return response.data;
  },

  verifyToken: async () => {
    const response = await api.get('/auth/verify');
    return response.data;
  }
};

// Serviços do backlog
export const backlogService = {
  getItems: async () => {
    const response = await api.get('/backlog');
    return response.data;
  },

  getItem: async (id) => {
    const response = await api.get(`/backlog/${id}`);
    return response.data;
  },

  createItem: async (itemData) => {
    const response = await api.post('/backlog', itemData);
    return response.data;
  },

  updateItem: async (id, itemData) => {
    const response = await api.put(`/backlog/${id}`, itemData);
    return response.data;
  },

  deleteItem: async (id) => {
    const response = await api.delete(`/backlog/${id}`);
    return response.data;
  },

  getStats: async () => {
    const response = await api.get('/backlog/stats');
    return response.data;
  }
};

// Função para verificar se o usuário está autenticado
export const isAuthenticated = () => {
  const token = localStorage.getItem('token');
  return !!token;
};

// Função para fazer logout
export const logout = () => {
  localStorage.removeItem('token');
  localStorage.removeItem('user');
  window.location.href = '/login';
};

export default api;

A URL http://localhost:3001/api está escrita no código. Se você mudar a porta da API, altere-a aqui.

Passo 17: contexto de autenticação

O AuthContext guarda o usuário logado e oferece login, register, logout e updateUser a toda a árvore de componentes. Ao carregar a página, ele reidrata a sessão a partir do localStorage e confirma o token com GET /auth/verify. Assim, atualizar a página não desloga o usuário.

Arquivo frontend/src/contexts/AuthContext.jsx

import React, { createContext, useContext, useState, useEffect } from 'react';
import { authService, isAuthenticated } from '../lib/api';

const AuthContext = createContext();

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth deve ser usado dentro de um AuthProvider');
  }
  return context;
};

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  // Verificar se o usuário está autenticado ao carregar a aplicação
  useEffect(() => {
    const checkAuth = async () => {
      try {
        if (isAuthenticated()) {
          const userData = JSON.parse(localStorage.getItem('user') || '{}');
          if (userData.id) {
            setUser(userData);
            setIsLoggedIn(true);
            
            // Verificar se o token ainda é válido
            try {
              await authService.verifyToken();
            } catch (error) {
              // Token inválido, fazer logout
              logout();
            }
          }
        }
      } catch (error) {
        console.error('Erro ao verificar autenticação:', error);
        logout();
      } finally {
        setLoading(false);
      }
    };

    checkAuth();
  }, []);

  const login = async (credentials) => {
    try {
      const response = await authService.login(credentials);
      
      if (response.success) {
        const { user: userData, token } = response.data;
        
        // Salvar no localStorage
        localStorage.setItem('token', token);
        localStorage.setItem('user', JSON.stringify(userData));
        
        // Atualizar estado
        setUser(userData);
        setIsLoggedIn(true);
        
        return { success: true, data: userData };
      }
      
      return { success: false, error: response.error || 'Erro no login' };
    } catch (error) {
      const errorMessage = error.response?.data?.error || 'Erro ao fazer login';
      return { success: false, error: errorMessage };
    }
  };

  const register = async (userData) => {
    try {
      const response = await authService.register(userData);
      
      if (response.success) {
        const { user: newUser, token } = response.data;
        
        // Salvar no localStorage
        localStorage.setItem('token', token);
        localStorage.setItem('user', JSON.stringify(newUser));
        
        // Atualizar estado
        setUser(newUser);
        setIsLoggedIn(true);
        
        return { success: true, data: newUser };
      }
      
      return { success: false, error: response.error || 'Erro no registro' };
    } catch (error) {
      const errorMessage = error.response?.data?.error || 'Erro ao registrar usuário';
      return { success: false, error: errorMessage };
    }
  };

  const logout = () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    setUser(null);
    setIsLoggedIn(false);
  };

  const updateUser = (userData) => {
    setUser(userData);
    localStorage.setItem('user', JSON.stringify(userData));
  };

  const value = {
    user,
    isLoggedIn,
    loading,
    login,
    register,
    logout,
    updateUser
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};

Passo 18: rotas e rota protegida

O ProtectedRoute decide o que renderizar. Enquanto a sessão é verificada, exibe um indicador de carregamento. Se o usuário estiver logado, renderiza o conteúdo. Caso contrário, redireciona para /login.

Arquivo frontend/src/components/ProtectedRoute.jsx

import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

const ProtectedRoute = ({ children }) => {
  const { isLoggedIn, loading } = useAuth();

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
      </div>
    );
  }

  return isLoggedIn ? children : <Navigate to="/login" replace />;
};

export default ProtectedRoute;

No App.jsx, o AuthProvider envolve o roteador. A raiz / e qualquer caminho desconhecido redirecionam para /dashboard, que por sua vez exige login.

flowchart TD
    R["/ ou caminho desconhecido"] --> D{"Rota /dashboard"}
    D -->|"sessão ativa"| P["Painel do backlog"]
    D -->|"sem sessão"| L["/login"]
    L -->|"login válido"| P
    L -->|"link Registre-se"| G["/register"]
    G -->|"registro válido"| P

Figura 4. Navegação entre as telas e papel da rota protegida.

Arquivo frontend/src/App.jsx

import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import Login from './components/Login';
import Register from './components/Register';
import Dashboard from './components/Dashboard';
import './App.css';

function App() {
  return (
    <AuthProvider>
      <Router>
        <div className="App">
          <Routes>
            {/* Rota raiz redireciona para dashboard */}
            <Route path="/" element={<Navigate to="/dashboard" replace />} />
            
            {/* Rotas públicas */}
            <Route path="/login" element={<Login />} />
            <Route path="/register" element={<Register />} />
            
            {/* Rotas protegidas */}
            <Route 
              path="/dashboard" 
              element={
                <ProtectedRoute>
                  <Dashboard />
                </ProtectedRoute>
              } 
            />
            
            {/* Rota 404 */}
            <Route path="*" element={<Navigate to="/dashboard" replace />} />
          </Routes>
        </div>
      </Router>
    </AuthProvider>
  );
}

export default App;

Arquivo frontend/src/main.jsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

Passo 19: telas de login e de registro

As duas telas têm a mesma estrutura. Um estado guarda os campos, outro guarda a mensagem de erro e outro indica carregamento. O handleChange atualiza o campo pelo atributo name do input. No registro, a confirmação de senha é validada apenas no navegador e removida antes do envio.

Arquivo frontend/src/components/Login.jsx

import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Film, Loader2 } from 'lucide-react';

const Login = () => {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  
  const { login } = useAuth();
  const navigate = useNavigate();

  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    });
    setError(''); // Limpar erro ao digitar
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    try {
      const result = await login(formData);
      
      if (result.success) {
        navigate('/dashboard');
      } else {
        setError(result.error);
      }
    } catch (error) {
      setError('Erro inesperado. Tente novamente.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
      <Card className="w-full max-w-md">
        <CardHeader className="text-center">
          <div className="flex justify-center mb-4">
            <Film className="h-12 w-12 text-blue-600" />
          </div>
          <CardTitle className="text-2xl font-bold">Entrar</CardTitle>
          <CardDescription>
            Acesse sua conta para gerenciar seu backlog
          </CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="space-y-4">
            {error && (
              <Alert variant="destructive">
                <AlertDescription>{error}</AlertDescription>
              </Alert>
            )}
            
            <div className="space-y-2">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                name="email"
                type="email"
                placeholder="[email protected]"
                value={formData.email}
                onChange={handleChange}
                required
                disabled={loading}
              />
            </div>
            
            <div className="space-y-2">
              <Label htmlFor="password">Senha</Label>
              <Input
                id="password"
                name="password"
                type="password"
                placeholder="Sua senha"
                value={formData.password}
                onChange={handleChange}
                required
                disabled={loading}
              />
            </div>
            
            <Button 
              type="submit" 
              className="w-full" 
              disabled={loading}
            >
              {loading ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  Entrando...
                </>
              ) : (
                'Entrar'
              )}
            </Button>
          </form>
          
          <div className="mt-6 text-center">
            <p className="text-sm text-gray-600">
              Não tem uma conta?{' '}
              <Link 
                to="/register" 
                className="text-blue-600 hover:text-blue-800 font-medium"
              >
                Registre-se
              </Link>
            </p>
          </div>
        </CardContent>
      </Card>
    </div>
  );
};

export default Login;

Arquivo frontend/src/components/Register.jsx

import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Film, Loader2 } from 'lucide-react';

const Register = () => {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: '',
    confirmPassword: ''
  });
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  
  const { register } = useAuth();
  const navigate = useNavigate();

  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    });
    setError(''); // Limpar erro ao digitar
  };

  const validateForm = () => {
    if (formData.password !== formData.confirmPassword) {
      setError('As senhas não coincidem');
      return false;
    }
    
    if (formData.password.length < 6) {
      setError('A senha deve ter pelo menos 6 caracteres');
      return false;
    }
    
    return true;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    if (!validateForm()) {
      setLoading(false);
      return;
    }

    try {
      const { confirmPassword, ...userData } = formData;
      const result = await register(userData);
      
      if (result.success) {
        navigate('/dashboard');
      } else {
        setError(result.error);
      }
    } catch (error) {
      setError('Erro inesperado. Tente novamente.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
      <Card className="w-full max-w-md">
        <CardHeader className="text-center">
          <div className="flex justify-center mb-4">
            <Film className="h-12 w-12 text-blue-600" />
          </div>
          <CardTitle className="text-2xl font-bold">Criar Conta</CardTitle>
          <CardDescription>
            Registre-se para começar a gerenciar seu backlog
          </CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="space-y-4">
            {error && (
              <Alert variant="destructive">
                <AlertDescription>{error}</AlertDescription>
              </Alert>
            )}
            
            <div className="space-y-2">
              <Label htmlFor="name">Nome</Label>
              <Input
                id="name"
                name="name"
                type="text"
                placeholder="Seu nome completo"
                value={formData.name}
                onChange={handleChange}
                required
                disabled={loading}
              />
            </div>
            
            <div className="space-y-2">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                name="email"
                type="email"
                placeholder="[email protected]"
                value={formData.email}
                onChange={handleChange}
                required
                disabled={loading}
              />
            </div>
            
            <div className="space-y-2">
              <Label htmlFor="password">Senha</Label>
              <Input
                id="password"
                name="password"
                type="password"
                placeholder="Mínimo 6 caracteres"
                value={formData.password}
                onChange={handleChange}
                required
                disabled={loading}
              />
            </div>
            
            <div className="space-y-2">
              <Label htmlFor="confirmPassword">Confirmar Senha</Label>
              <Input
                id="confirmPassword"
                name="confirmPassword"
                type="password"
                placeholder="Confirme sua senha"
                value={formData.confirmPassword}
                onChange={handleChange}
                required
                disabled={loading}
              />
            </div>
            
            <Button 
              type="submit" 
              className="w-full" 
              disabled={loading}
            >
              {loading ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  Criando conta...
                </>
              ) : (
                'Criar Conta'
              )}
            </Button>
          </form>
          
          <div className="mt-6 text-center">
            <p className="text-sm text-gray-600">
              Já tem uma conta?{' '}
              <Link 
                to="/login" 
                className="text-blue-600 hover:text-blue-800 font-medium"
              >
                Faça login
              </Link>
            </p>
          </div>
        </CardContent>
      </Card>
    </div>
  );
};

export default Register;

Passo 20: painel do backlog

O Dashboard é o componente central. Ao montar, busca a lista e as estatísticas em paralelo com Promise.all. Um único formulário em diálogo serve para criar e para editar. A variável editingItem define o modo. Após cada alteração, o componente recarrega os dados do servidor, o que mantém a tela sempre consistente com o banco, ao custo de uma requisição extra.

Arquivo frontend/src/components/Dashboard.jsx

import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { backlogService } from '../lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Film, Plus, Edit, Trash2, LogOut, User, BarChart3, Loader2 } from 'lucide-react';

const Dashboard = () => {
  const { user, logout } = useAuth();
  const [items, setItems] = useState([]);
  const [stats, setStats] = useState({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const [editingItem, setEditingItem] = useState(null);
  const [formData, setFormData] = useState({
    titulo: '',
    tipo: '',
    ano: new Date().getFullYear()
  });

  // Carregar dados ao montar o componente
  useEffect(() => {
    loadData();
  }, []);

  const loadData = async () => {
    try {
      setLoading(true);
      const [itemsResponse, statsResponse] = await Promise.all([
        backlogService.getItems(),
        backlogService.getStats()
      ]);
      
      if (itemsResponse.success) {
        setItems(itemsResponse.data);
      }
      
      if (statsResponse.success) {
        setStats(statsResponse.data);
      }
    } catch (error) {
      setError('Erro ao carregar dados');
      console.error('Erro:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');

    try {
      let response;
      if (editingItem) {
        response = await backlogService.updateItem(editingItem.id, formData);
      } else {
        response = await backlogService.createItem(formData);
      }

      if (response.success) {
        await loadData(); // Recarregar dados
        setIsDialogOpen(false);
        resetForm();
      } else {
        setError(response.error);
      }
    } catch (error) {
      setError(error.response?.data?.error || 'Erro ao salvar item');
    }
  };

  const handleEdit = (item) => {
    setEditingItem(item);
    setFormData({
      titulo: item.titulo,
      tipo: item.tipo,
      ano: item.ano
    });
    setIsDialogOpen(true);
  };

  const handleDelete = async (id) => {
    if (!confirm('Tem certeza que deseja remover este item?')) return;

    try {
      const response = await backlogService.deleteItem(id);
      if (response.success) {
        await loadData(); // Recarregar dados
      } else {
        setError(response.error);
      }
    } catch (error) {
      setError(error.response?.data?.error || 'Erro ao remover item');
    }
  };

  const resetForm = () => {
    setFormData({
      titulo: '',
      tipo: '',
      ano: new Date().getFullYear()
    });
    setEditingItem(null);
    setError('');
  };

  const openAddDialog = () => {
    resetForm();
    setIsDialogOpen(true);
  };

  const getTipoColor = (tipo) => {
    switch (tipo) {
      case 'FILME': return 'bg-blue-100 text-blue-800';
      case 'SERIE': return 'bg-green-100 text-green-800';
      case 'ANIME': return 'bg-purple-100 text-purple-800';
      default: return 'bg-gray-100 text-gray-800';
    }
  };

  const formatDate = (dateString) => {
    return new Date(dateString).toLocaleDateString('pt-BR');
  };

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <Loader2 className="h-8 w-8 animate-spin" />
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50">
      {/* Header */}
      <header className="bg-white shadow-sm border-b">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
          <div className="flex justify-between items-center h-16">
            <div className="flex items-center">
              <Film className="h-8 w-8 text-blue-600 mr-3" />
              <h1 className="text-xl font-semibold text-gray-900">
                Meu Backlog
              </h1>
            </div>
            <div className="flex items-center space-x-4">
              <span className="text-sm text-gray-600">
                Olá, {user?.name}
              </span>
              <Button variant="outline" size="sm" onClick={logout}>
                <LogOut className="h-4 w-4 mr-2" />
                Sair
              </Button>
            </div>
          </div>
        </div>
      </header>

      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        {error && (
          <Alert variant="destructive" className="mb-6">
            <AlertDescription>{error}</AlertDescription>
          </Alert>
        )}

        {/* Estatísticas */}
        <div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
          <Card>
            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
              <CardTitle className="text-sm font-medium">Total</CardTitle>
              <BarChart3 className="h-4 w-4 text-muted-foreground" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stats.total || 0}</div>
            </CardContent>
          </Card>
          
          <Card>
            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
              <CardTitle className="text-sm font-medium">Filmes</CardTitle>
              <Film className="h-4 w-4 text-blue-600" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stats.porTipo?.filme || 0}</div>
            </CardContent>
          </Card>
          
          <Card>
            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
              <CardTitle className="text-sm font-medium">Séries</CardTitle>
              <Film className="h-4 w-4 text-green-600" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stats.porTipo?.serie || 0}</div>
            </CardContent>
          </Card>
          
          <Card>
            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
              <CardTitle className="text-sm font-medium">Animes</CardTitle>
              <Film className="h-4 w-4 text-purple-600" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stats.porTipo?.anime || 0}</div>
            </CardContent>
          </Card>
        </div>

        {/* Botão Adicionar */}
        <div className="flex justify-between items-center mb-6">
          <h2 className="text-2xl font-bold text-gray-900">Meus Itens</h2>
          <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
            <DialogTrigger asChild>
              <Button onClick={openAddDialog}>
                <Plus className="h-4 w-4 mr-2" />
                Adicionar Item
              </Button>
            </DialogTrigger>
            <DialogContent>
              <DialogHeader>
                <DialogTitle>
                  {editingItem ? 'Editar Item' : 'Adicionar Novo Item'}
                </DialogTitle>
                <DialogDescription>
                  {editingItem ? 'Edite as informações do item.' : 'Adicione um novo filme, série ou anime ao seu backlog.'}
                </DialogDescription>
              </DialogHeader>
              
              <form onSubmit={handleSubmit} className="space-y-4">
                <div className="space-y-2">
                  <Label htmlFor="titulo">Título</Label>
                  <Input
                    id="titulo"
                    value={formData.titulo}
                    onChange={(e) => setFormData({...formData, titulo: e.target.value})}
                    placeholder="Nome do filme, série ou anime"
                    required
                  />
                </div>
                
                <div className="space-y-2">
                  <Label htmlFor="tipo">Tipo</Label>
                  <Select value={formData.tipo} onValueChange={(value) => setFormData({...formData, tipo: value})}>
                    <SelectTrigger>
                      <SelectValue placeholder="Selecione o tipo" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="FILME">Filme</SelectItem>
                      <SelectItem value="SERIE">Série</SelectItem>
                      <SelectItem value="ANIME">Anime</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
                
                <div className="space-y-2">
                  <Label htmlFor="ano">Ano</Label>
                  <Input
                    id="ano"
                    type="number"
                    value={formData.ano}
                    onChange={(e) => setFormData({...formData, ano: parseInt(e.target.value)})}
                    min="1900"
                    max={new Date().getFullYear() + 5}
                    required
                  />
                </div>
                
                <div className="flex justify-end space-x-2">
                  <Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
                    Cancelar
                  </Button>
                  <Button type="submit">
                    {editingItem ? 'Salvar' : 'Adicionar'}
                  </Button>
                </div>
              </form>
            </DialogContent>
          </Dialog>
        </div>

        {/* Lista de Itens */}
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {items.length === 0 ? (
            <div className="col-span-full text-center py-12">
              <Film className="h-12 w-12 text-gray-400 mx-auto mb-4" />
              <h3 className="text-lg font-medium text-gray-900 mb-2">
                Nenhum item no backlog
              </h3>
              <p className="text-gray-600 mb-4">
                Comece adicionando filmes, séries ou animes que você assistiu.
              </p>
              <Button onClick={openAddDialog}>
                <Plus className="h-4 w-4 mr-2" />
                Adicionar Primeiro Item
              </Button>
            </div>
          ) : (
            items.map((item) => (
              <Card key={item.id} className="hover:shadow-md transition-shadow">
                <CardHeader>
                  <div className="flex justify-between items-start">
                    <div className="flex-1">
                      <CardTitle className="text-lg">{item.titulo}</CardTitle>
                      <CardDescription>Ano: {item.ano}</CardDescription>
                    </div>
                    <Badge className={getTipoColor(item.tipo)}>
                      {item.tipo.toLowerCase()}
                    </Badge>
                  </div>
                </CardHeader>
                <CardContent>
                  <div className="flex justify-between items-center">
                    <div className="text-sm text-gray-600">
                      Adicionado em {formatDate(item.dataAdicao)}
                    </div>
                    <div className="flex space-x-2">
                      <Button
                        size="sm"
                        variant="outline"
                        onClick={() => handleEdit(item)}
                      >
                        <Edit className="h-4 w-4" />
                      </Button>
                      <Button
                        size="sm"
                        variant="outline"
                        onClick={() => handleDelete(item.id)}
                      >
                        <Trash2 className="h-4 w-4" />
                      </Button>
                    </div>
                  </div>
                </CardContent>
              </Card>
            ))
          )}
        </div>
      </div>
    </div>
  );
};

export default Dashboard;

Os cartões de estatística leem stats.porTipo?.filme, serie e anime. Essas chaves em minúsculas vêm de getBacklogStats, que aplica toLowerCase() ao tipo. A cor do selo muda conforme o tipo, na função getTipoColor.

Executando a aplicação localmente

Com os arquivos no lugar, o roteiro de execução tem cinco etapas.

1. Banco de dados

Suba o MySQL como no Passo 3 e confirme que o banco responde.

2. Back-end

cd backend
npm install
npx prisma migrate deploy
npx prisma generate
npm run dev

Se você criou o projeto do zero, a migração já existe e migrate deploy apenas a aplica. A saída esperada contém as linhas abaixo.

✅ Conectado ao banco de dados MySQL
🚀 Servidor rodando na porta 3001

Teste rapidamente a API em outro terminal.

curl http://localhost:3001/api/health

3. Front-end

cd frontend
pnpm install
pnpm dev

Se preferir npm, use npm install --legacy-peer-deps. Sem a opção, o npm aborta com erro ERESOLVE, porque o [email protected] (dependência do projeto original, hoje sem uso nas telas) exige date-fns na versão 2 ou 3, e o projeto declara a 4. O pnpm tolera essa divergência. No projeto que você montou pelo Passo 12, essa dependência não existe e o problema não ocorre.

O Vite informa o endereço, normalmente http://localhost:5173.

4. Uso pela interface

  1. Abra http://localhost:5173. Você será levado a /login.
  2. Clique em Registre-se, informe nome, e-mail e senha de 6 ou mais caracteres e confirme a senha.
  3. Após o registro, você já entra no painel, com os quatro contadores zerados.
  4. Clique em Adicionar Item, preencha título, tipo e ano e salve.
  5. Use os ícones de lápis e de lixeira para editar e remover. Observe os contadores mudarem.
  6. Clique em Sair e entre de novo para confirmar que os itens persistem.

5. Teste da API com curl

Salve o roteiro abaixo como testar-api.sh. Ele registra um usuário, exercita os endpoints e mostra as respostas. Requer apenas bash, curl e node. A variável API permite apontar para outra porta.

Arquivo testar-api.sh

#!/usr/bin/env bash
# Teste de ponta a ponta da API do backlog, usando apenas curl e Node.
API="${API:-http://localhost:3001/api}"
EMAIL="maria$(date +%s)@exemplo.com"

# req MÉTODO CAMINHO [TOKEN] [CORPO_JSON]
req() {
  local metodo="$1" caminho="$2" token="$3" corpo="$4"
  local args=(-s -w ' [HTTP %{http_code}]' -X "$metodo" "$API$caminho" -H 'Content-Type: application/json')
  [ -n "$token" ] && args+=(-H "Authorization: Bearer $token")
  [ -n "$corpo" ] && args+=(-d "$corpo")
  echo "== $metodo $caminho"
  curl "${args[@]}" | sed -E 's/"token":"[^"]+"/"token":"<jwt>"/'
  echo
}

# campo EXPRESSÃO_JS: lê JSON da entrada padrão e imprime o valor
campo() { node -pe "JSON.parse(require('fs').readFileSync(0,'utf8'))$1"; }

req GET /health

REGISTRO=$(curl -s -X POST "$API/auth/register" -H 'Content-Type: application/json' \
  -d "{\"name\":\"Maria Souza\",\"email\":\"$EMAIL\",\"password\":\"segredo123\"}")
TOKEN=$(echo "$REGISTRO" | campo .data.token)
echo "== POST /auth/register"; echo "$REGISTRO" | sed -E 's/"token":"[^"]+"/"token":"<jwt>"/'

req POST /auth/register "" "{\"name\":\"Maria\",\"email\":\"$EMAIL\",\"password\":\"segredo123\"}"
req POST /auth/login "" "{\"email\":\"$EMAIL\",\"password\":\"errada\"}"
req GET /backlog
req POST /backlog "$TOKEN" '{"titulo":"Vingadores: Ultimato","tipo":"filme","ano":2019}'
req POST /backlog "$TOKEN" '{"titulo":"Frieren","tipo":"ANIME","ano":2023}'
req POST /backlog "$TOKEN" '{"titulo":"Jogo","tipo":"JOGO","ano":2020}'

ID=$(curl -s "$API/backlog" -H "Authorization: Bearer $TOKEN" | campo '.data[0].id')
req GET /backlog "$TOKEN"
req GET /backlog/stats "$TOKEN"
req PUT "/backlog/$ID" "$TOKEN" '{"ano":2018}'
req DELETE "/backlog/$ID" "$TOKEN"
req GET "/backlog/$ID" "$TOKEN"

Execute com bash testar-api.sh. Esta é a saída obtida na execução de verificação, com a API em localhost:3001 e o MySQL 8.0.42. Os identificadores, as datas e o e-mail variam a cada execução. O token foi mascarado pelo próprio script.

== GET /health
{"message":"API do Backlog funcionando!","timestamp":"2026-11-12T18:31:19.039Z"} [HTTP 200]
== POST /auth/register
{"success":true,"data":{"user":{"id":"a1f210ab-d6fa-458d-aee6-576ac18c5869","email":"[email protected]","name":"Maria Souza","createdAt":"2026-11-12T18:31:19.780Z","updatedAt":"2026-11-12T18:31:19.780Z"},"token":"<jwt>"},"message":"Usuário registrado com sucesso"}
== POST /auth/register
{"success":false,"error":"Este email já está cadastrado"} [HTTP 400]
== POST /auth/login
{"success":false,"error":"Credenciais inválidas"} [HTTP 401]
== GET /backlog
{"success":false,"error":"Token de acesso requerido","message":"Você precisa estar logado para acessar este recurso"} [HTTP 401]
== POST /backlog
{"success":true,"data":{"id":"fd311911-052c-484c-84ec-d5ba28da42af","titulo":"Vingadores: Ultimato","tipo":"FILME","ano":2019,"dataAdicao":"2026-11-12T18:31:20.663Z","dataModificacao":"2026-11-12T18:31:20.663Z","userId":"a1f210ab-d6fa-458d-aee6-576ac18c5869"},"message":"Item adicionado ao backlog com sucesso"} [HTTP 201]
== POST /backlog
{"success":true,"data":{"id":"97cdc1e7-4f3a-4ba5-8fda-b22ed3594586","titulo":"Frieren","tipo":"ANIME","ano":2023,"dataAdicao":"2026-11-12T18:31:20.693Z","dataModificacao":"2026-11-12T18:31:20.693Z","userId":"a1f210ab-d6fa-458d-aee6-576ac18c5869"},"message":"Item adicionado ao backlog com sucesso"} [HTTP 201]
== POST /backlog
{"success":false,"error":"Tipo deve ser: FILME, SERIE ou ANIME"} [HTTP 400]
== GET /backlog
{"success":true,"data":[{"id":"97cdc1e7-4f3a-4ba5-8fda-b22ed3594586","titulo":"Frieren","tipo":"ANIME","ano":2023,"dataAdicao":"2026-11-12T18:31:20.693Z","dataModificacao":"2026-11-12T18:31:20.693Z","userId":"a1f210ab-d6fa-458d-aee6-576ac18c5869"},{"id":"fd311911-052c-484c-84ec-d5ba28da42af","titulo":"Vingadores: Ultimato","tipo":"FILME","ano":2019,"dataAdicao":"2026-11-12T18:31:20.663Z","dataModificacao":"2026-11-12T18:31:20.663Z","userId":"a1f210ab-d6fa-458d-aee6-576ac18c5869"}],"total":2} [HTTP 200]
== GET /backlog/stats
{"success":true,"data":{"total":2,"porTipo":{"anime":1,"filme":1}}} [HTTP 200]
== PUT /backlog/97cdc1e7-4f3a-4ba5-8fda-b22ed3594586
{"success":true,"data":{"id":"97cdc1e7-4f3a-4ba5-8fda-b22ed3594586","titulo":"Frieren","tipo":"ANIME","ano":2018,"dataAdicao":"2026-11-12T18:31:20.693Z","dataModificacao":"2026-11-12T18:31:20.982Z","userId":"a1f210ab-d6fa-458d-aee6-576ac18c5869"},"message":"Item atualizado com sucesso"} [HTTP 200]
== DELETE /backlog/97cdc1e7-4f3a-4ba5-8fda-b22ed3594586
{"success":true,"message":"Item removido do backlog com sucesso"} [HTTP 200]
== GET /backlog/97cdc1e7-4f3a-4ba5-8fda-b22ed3594586
{"success":false,"error":"Item não encontrado"} [HTTP 404]

Leia a saída com atenção. O POST repetido de registro devolve 400 por e-mail duplicado. O login com senha errada devolve 401 com mensagem genérica. A listagem sem token devolve 401. O tipo JOGO é rejeitado com 400. O tipo filme em minúsculas foi aceito e gravado como FILME. A estatística conta filme e anime. A edição muda apenas o ano. E a leitura de um item já removido devolve 404.

Uma verificação adicional, feita à mão fora do roteiro, confirmou a isolação entre usuários. Um segundo usuário que pede o id de um item do primeiro recebe 404 com Item não encontrado. Um token malformado recebe 401 com Token inválido.

Limitações e pontos de melhoria

Este projeto não está pronto para produção e de forma nenhuma se pretendia que estivesse. Aqui listamos alguns pontos que merecem discussão e são pasśiveis de melhoria:

  • CORS aberto. origin: '*' aceita qualquer origem. Em produção, use uma lista de origens permitidas.
  • Redirecionamento indevido no login. O interceptador de resposta redireciona para /login em qualquer 401. O login com senha errada também responde 401. Pelo código, a página é recarregada e a mensagem de erro do formulário se perde. Não observamos isso em navegador.
  • Token no localStorage. Qualquer script que rode na página, como um XSS, consegue lê-lo. Uma alternativa é um cookie HttpOnly.
  • Sem limite de tentativas. Não há rate limiting no login.
  • Validação manual e parcial. Os controladores validam à mão. Uma biblioteca de esquemas, como o Zod (que o front-end já tem instalado), tornaria isso declarativo. Além disso, ano igual a 0 é tratado como ausente, e um tipo que não seja texto provoca erro 500.
  • Sem paginação. GET /api/backlog devolve todos os itens do usuário.
  • Código redundante. O uuidv4() é desnecessário, pois o Prisma já gera o id. A função optionalAuth não é usada. O prisma está em dependencies, mas é ferramenta de desenvolvimento.
  • URL da API fixa no front-end. O ideal é lê-la de uma variável de ambiente do Vite.
  • Segredo de exemplo. O .env.example traz um JWT_SECRET ilustrativo. Em qualquer ambiente real, gere um valor aleatório longo.
  • Documentação desatualizada. O README original cita um caminho de front-end que não existe. A estrutura mostrada neste artigo reflete a pasta real.

Para um estudo mais completo, as melhorias acima viram bons trabalhos. Adicionar um campo nota ou status ao modelo e propagá-lo por migração, API e tela também é uma ótima pedida.

Outras possibilidades são criar filtros e paginação, escrever testes de integração com supertest, trocar o localStorage por cookie HttpOnly e publicar a API e o front-end em um provedor na nuvem.

Conclusão

O Backlog de filmes, séries e animes é pequeno e, justamente por isso, didático. Ele mostra uma API REST com Express, persistência relacional com Prisma, autenticação stateless com JWT e bcrypt e uma interface React com rotas protegidas e CRUD completo. A separação entre rotas, controladores e acesso a dados torna cada arquivo curto e de responsabilidade clara. O filtro por userId, derivado do token, resolve o requisito de isolamento entre usuários em uma linha por consulta..

O próximo passo natural é tratar as limitações como tarefas de melhoria para este sistema. Cada uma delas exercita um conceito importante de engenharia de software, de segurança a testes automatizados.

Obrigado pela leitura e bons estudos!

Referências

FIELDING, Roy Thomas. Architectural styles and the design of network-based software architectures. 2000. Tese (Doutorado em Ciência da Informação e da Computação) – University of California, Irvine, Irvine, 2000.

FIELDING, R.; NOTTINGHAM, M.; RESCHKE, J. (ed.). HTTP semantics. RFC 9110. [S. l.]: Internet Engineering Task Force, 2022.

JONES, M.; BRADLEY, J.; SAKIMURA, N. JSON Web Token (JWT). RFC 7519. [S. l.]: Internet Engineering Task Force, 2015.

KRASNER, Glenn E.; POPE, Stephen T. A cookbook for using the model-view controller user interface paradigm in Smalltalk-80. Journal of Object-Oriented Programming, v. 1, n. 3, p. 26-49, 1988.

PROVOS, Niels; MAZIÈRES, David. A future-adaptable password scheme. In: USENIX ANNUAL TECHNICAL CONFERENCE, 1999, Monterey. Proceedings […]. Berkeley: USENIX Association, 1999.