Construindo um backlog de filmes, séries e animes com Node, Prisma, MySQL e interface React
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ódigo | Requisito |
|---|---|
| RF01 | O visitante pode criar uma conta informando nome, e-mail e senha. |
| RF02 | O usuário pode entrar com e-mail e senha e recebe um token de acesso. |
| RF03 | Somente usuários autenticados acessam o backlog. |
| RF04 | O usuário cadastra um item com título, tipo (filme, série ou anime) e ano. |
| RF05 | O usuário lista, consulta, edita e remove os próprios itens. |
| RF06 | O sistema registra automaticamente a data de adição e a data da última modificação. |
| RF07 | O painel mostra o total de itens e a contagem por tipo. |
| RF08 | O usuário consulta e atualiza o próprio perfil (nome e e-mail). |
| RF09 | Um usuário nunca enxerga nem altera itens de outro. |
Requisitos não funcionais
| Código | Requisito |
|---|---|
| RNF01 | Senhas são armazenadas somente como hash, nunca em texto claro. |
| RNF02 | A sessão é stateless: o servidor não guarda sessões, apenas valida o token. |
| RNF03 | Segredos e credenciais ficam em variáveis de ambiente, fora do código. |
| RNF04 | O back-end segue a separação em rotas, controladores e acesso a dados. |
| RNF05 | A interface é responsiva e dá retorno visual de carregamento e de erro. |
| RNF06 | A 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,SERIEouANIME. 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
| Tecnologia | Versão no projeto | Papel |
|---|---|---|
| Node.js | 18 ou superior | Ambiente de execução JavaScript. |
| Express | ^4.18.2 (instalada: 4.21.2) | Framework HTTP, rotas e middlewares. |
| Prisma | ^6.15.0 | ORM, esquema do banco e migrações. |
| MySQL | 8 ou superior | Banco de dados relacional. |
| jsonwebtoken | ^9.0.2 | Geração e verificação de tokens JWT. |
| bcryptjs | ^3.0.2 | Hash de senhas com salt. |
| cors | ^2.8.5 | Cabeçalhos CORS. |
| dotenv | ^17.2.1 | Leitura do arquivo .env. |
| uuid | ^11.1.0 | Geração de identificadores. |
| nodemon | ^3.1.10 | Recarga automática em desenvolvimento. |
Tecnologias do front-end
| Tecnologia | Versão no projeto | Papel |
|---|---|---|
| React | ^19.1.0 | Biblioteca de interface. |
| Vite | ^6.3.5 | Servidor de desenvolvimento e empacotador. |
| React Router | ^7.6.1 | Rotas no navegador. |
| Axios | ^1.11.0 | Cliente HTTP com interceptadores. |
| Tailwind CSS | ^4.1.7 | Estilos utilitários. |
| shadcn/ui e Radix UI | componentes copiados para o projeto | Botõ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étodo | Caminho | Autenticação | Corpo | Sucesso | Erros |
|---|---|---|---|---|---|
| GET | /api/health | não | n/d | 200 | n/d |
| POST | /api/auth/register | não | name, email, password | 201 | 400 |
| POST | /api/auth/login | não | email, password | 200 | 400, 401 |
| GET | /api/auth/profile | sim | n/d | 200 | 401, 404 |
| PUT | /api/auth/profile | sim | name e/ou email | 200 | 400, 401 |
| GET | /api/auth/verify | sim | n/d | 200 | 401 |
| GET | /api/backlog | sim | n/d | 200 | 401 |
| GET | /api/backlog/stats | sim | n/d | 200 | 401 |
| GET | /api/backlog/:id | sim | n/d | 200 | 401, 404 |
| POST | /api/backlog | sim | titulo, tipo, ano | 201 | 400, 401 |
| PUT | /api/backlog/:id | sim | titulo, tipo e/ou ano | 200 | 400, 401, 404 |
| DELETE | /api/backlog/:id | sim | n/d | 200 | 401, 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
- Abra
http://localhost:5173. Você será levado a/login. - Clique em Registre-se, informe nome, e-mail e senha de 6 ou mais caracteres e confirme a senha.
- Após o registro, você já entra no painel, com os quatro contadores zerados.
- Clique em Adicionar Item, preencha título, tipo e ano e salve.
- Use os ícones de lápis e de lixeira para editar e remover. Observe os contadores mudarem.
- 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
/loginem 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 cookieHttpOnly. - 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,
anoigual a 0 é tratado como ausente, e umtipoque não seja texto provoca erro 500. - Sem paginação.
GET /api/backlogdevolve todos os itens do usuário. - Código redundante. O
uuidv4()é desnecessário, pois o Prisma já gera oid. A funçãooptionalAuthnão é usada. Oprismaestá emdependencies, 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.exampletraz umJWT_SECRETilustrativo. 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.